Compare commits

..

15 Commits

Author SHA1 Message Date
Juan Felipe Zapata Moreno
fb0e2e7333 ADD: Remisión 2025-10-10 16:27:40 -06:00
Juan Felipe Zapata Moreno
47764891d2 ADD: Factura pdf 2025-10-09 13:27:54 -06:00
Juan Felipe Zapata Moreno
f277c3677a ADD: Plantilla Doc WIP 2025-10-03 16:30:33 -06:00
Juan Felipe Zapata Moreno
d7887d028c ADD: Plantillas WIP 2025-10-02 16:38:45 -06:00
Juan Felipe Zapata Moreno
4518be3887 DELETE: Extensiones tiptap, univer desintaladas 2025-10-01 15:45:40 -06:00
Juan Felipe Zapata Moreno
1ce1fb30fc FIX: Tabla WIP 2025-09-30 16:22:56 -06:00
Juan Felipe Zapata Moreno
b2095e4559 Cambios minimos al canvas 2025-09-26 16:14:42 -06:00
Juan Felipe Zapata Moreno
21f5d3a761 ADD: Univer docs 2025-09-25 15:58:11 -06:00
72d4423d67 Diseñador de Plantilla 2025-09-25 00:51:36 -06:00
9fbcc76638 Conflictos resueltos 2025-09-24 18:31:31 -06:00
Juan Felipe Zapata Moreno
a6abe2de40 Cambios al maquetador 2025-09-24 16:40:29 -06:00
19ae058e2d vue datepicker y layouts separados 2025-09-23 19:28:38 -06:00
433994cda2 Maquetador de documentos (#2)
Co-authored-by: Juan Felipe Zapata Moreno <zapata_pipe@hotmail.com>
Reviewed-on: #2
2025-09-23 19:28:13 -06:00
703b39e052 redireccion 2025-09-23 19:26:09 -06:00
Juan Felipe Zapata Moreno
5e56c71bca ADD: Se arregló el toolbar para cambios al texto así como la selección de hoja 2025-09-23 15:49:18 -06:00
40 changed files with 7491 additions and 3423 deletions

View File

@ -1,10 +1,10 @@
VITE_API_URL=http://backend.holos.test:8080
VITE_BASE_URL=http://frontend.holos.test
VITE_API_URL=http://localhost:8080
VITE_BASE_URL=http://localhost:3000
VITE_REVERB_APP_ID=
VITE_REVERB_APP_KEY=
VITE_REVERB_APP_SECRET=
VITE_REVERB_HOST="backend.holos.test"
VITE_REVERB_HOST="localhost"
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_REVERB_ACTIVE=false

View File

@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/pdfmake.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/vfs_fonts.js"></script>
<title>Holos</title>
</head>
<body>

2488
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,16 +13,30 @@
"@popperjs/core": "^2.11.8",
"@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",
"@tiptap/starter-kit": "^3.5.2",
"@tiptap/vue-3": "^3.5.2",
"@vitejs/plugin-vue": "^5.2.1",
"@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",
"luxon": "^3.5.0",
"pdf-lib": "^1.17.1",
"pinia": "^3.0.1",
"pusher-js": "^8.4.0",
"tailwindcss": "^4.0",
"tiptap-extension-font-size": "^1.2.0",
"toastr": "^2.1.4",
"uuid": "^11.1.0",
"v-calendar": "^3.1.2",
@ -37,5 +51,8 @@
"devDependencies": {
"autoprefixer": "^10.4.20",
"vite-plugin-html": "^3.2.2"
},
"overrides": {
"redi": "0.1.12"
}
}

View File

@ -1,606 +0,0 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
element: {
type: Object,
required: true
},
isSelected: {
type: Boolean,
default: false
}
});
/** Eventos */
const emit = defineEmits(['select', 'delete', 'update', 'move']);
/** Referencias */
const isEditing = ref(false);
const editValue = ref('');
const editInput = ref(null);
const editTextarea = ref(null);
const elementRef = ref(null);
const isDragging = ref(false);
const isResizing = ref(false);
const resizeDirection = ref(null); // 'corner', 'right', 'bottom'
const dragStart = ref({ x: 0, y: 0 });
const resizeStart = ref({ x: 0, y: 0, width: 0, height: 0 });
const fileInput = ref(null);
/** Propiedades computadas */
const elementStyles = computed(() => ({
left: `${props.element.x}px`,
top: `${props.element.y}px`,
width: `${props.element.width || 200}px`,
height: `${props.element.height || 40}px`
}));
/** Watchers */
watch(() => props.isSelected, (selected) => {
if (selected && isEditing.value) {
nextTick(() => {
if (props.element.type === 'text' && editInput.value) {
editInput.value.focus();
editInput.value.select();
} else if (props.element.type === 'code' && editTextarea.value) {
editTextarea.value.focus();
editTextarea.value.select();
}
});
}
});
/** Métodos */
const handleSelect = (event) => {
event.stopPropagation();
emit('select', props.element.id);
};
const handleDelete = () => {
emit('delete', props.element.id);
};
const startEditing = () => {
if (props.element.type === 'table' && props.element.content) {
// Deep copy para evitar mutaciones directas
editValue.value = JSON.parse(JSON.stringify(props.element.content));
} else if (props.element.type === 'code') {
editValue.value = props.element.content || 'console.log("Hola mundo");';
} else {
editValue.value = props.element.content || getDefaultEditValue();
}
isEditing.value = true;
nextTick(() => {
if (editTextarea.value) editTextarea.value.focus();
if (editInput.value) editInput.value.focus();
});
};
const finishEditing = () => {
if (isEditing.value) {
isEditing.value = false;
// Para tablas, emitir el objeto completo
if (props.element.type === 'table') {
emit('update', {
id: props.element.id,
content: editValue.value
});
} else {
emit('update', {
id: props.element.id,
content: editValue.value
});
}
}
};
const handleKeydown = (event) => {
if (props.element.type === 'text') {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
finishEditing();
} else if (event.key === 'Escape') {
isEditing.value = false;
editValue.value = props.element.content || 'Nuevo texto';
}
} else if (props.element.type === 'code') {
if (event.key === 'Escape') {
isEditing.value = false;
editValue.value = props.element.content || 'console.log("Hola mundo");';
}
// Para código, permitimos Enter normal y usamos Ctrl+Enter para terminar
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
finishEditing();
}
} else if (props.element.type === 'table') {
if (event.key === 'Escape') {
isEditing.value = false;
// Restaurar el contenido original de la tabla
editValue.value = props.element.content ?
JSON.parse(JSON.stringify(props.element.content)) :
getDefaultEditValue();
}
// Para tablas, Enter normal para nueva línea en celda, Ctrl+Enter para terminar
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
finishEditing();
}
}
};
// Manejo de archivo de imagen
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
emit('update', {
id: props.element.id,
content: e.target.result,
fileName: file.name
});
};
reader.readAsDataURL(file);
}
// Limpiar el input
event.target.value = '';
};
// Funcionalidad de arrastre
const handleMouseDown = (event) => {
if (isEditing.value || isResizing.value) return;
isDragging.value = true;
dragStart.value = {
x: event.clientX - props.element.x,
y: event.clientY - props.element.y
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
event.preventDefault();
};
const handleMouseMove = (event) => {
if (isDragging.value && !isResizing.value) {
const newX = event.clientX - dragStart.value.x;
const newY = event.clientY - dragStart.value.y;
emit('move', {
id: props.element.id,
x: Math.max(0, newX),
y: Math.max(0, newY)
});
} else if (isResizing.value && !isDragging.value) {
handleResizeMove(event);
}
};
const handleMouseUp = () => {
isDragging.value = false;
isResizing.value = false;
resizeDirection.value = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
// Funcionalidad de redimensionamiento por esquina
const startResize = (event) => {
event.stopPropagation();
event.preventDefault();
isResizing.value = true;
resizeDirection.value = 'corner';
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: props.element.width || 200,
height: props.element.height || 40
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// Funcionalidad de redimensionamiento por bordes
const startResizeEdge = (event, direction) => {
event.stopPropagation();
event.preventDefault();
isResizing.value = true;
resizeDirection.value = direction;
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: props.element.width || 200,
height: props.element.height || 40
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleResizeMove = (event) => {
if (!isResizing.value) return;
const deltaX = event.clientX - resizeStart.value.x;
const deltaY = event.clientY - resizeStart.value.y;
let newWidth = resizeStart.value.width;
let newHeight = resizeStart.value.height;
// Calcular nuevas dimensiones según la dirección
if (resizeDirection.value === 'corner') {
newWidth = Math.max(getMinWidth(), Math.min(getMaxWidth(), resizeStart.value.width + deltaX));
newHeight = Math.max(getMinHeight(), Math.min(getMaxHeight(), resizeStart.value.height + deltaY));
} else if (resizeDirection.value === 'right') {
newWidth = Math.max(getMinWidth(), Math.min(getMaxWidth(), resizeStart.value.width + deltaX));
} else if (resizeDirection.value === 'bottom') {
newHeight = Math.max(getMinHeight(), Math.min(getMaxHeight(), resizeStart.value.height + deltaY));
}
emit('update', {
id: props.element.id,
width: newWidth,
height: newHeight
});
};
// Obtener tamaños mínimos según el tipo de elemento
const getMinWidth = () => {
switch (props.element.type) {
case 'text':
return 100;
case 'image':
return 100;
case 'table':
return 200;
default:
return 100;
}
};
const getMinHeight = () => {
switch (props.element.type) {
case 'text':
return 30;
case 'image':
return 80;
case 'table':
return 80;
default:
return 30;
}
};
// Obtener tamaños máximos según el tipo de elemento
const getMaxWidth = () => {
return 800; // Máximo general
};
const getMaxHeight = () => {
return 600; // Máximo general
};
</script>
<template>
<div
ref="elementRef"
:style="elementStyles"
@click="handleSelect"
@dblclick="startEditing"
@mousedown="handleMouseDown"
class="absolute group select-none"
:class="{
'ring-2 ring-blue-500 ring-opacity-50': isSelected,
'cursor-move': !isEditing && !isResizing,
'cursor-text': isEditing && (element.type === 'text' || element.type === 'code'),
'cursor-se-resize': isResizing && resizeDirection === 'corner',
'cursor-e-resize': isResizing && resizeDirection === 'right',
'cursor-s-resize': isResizing && resizeDirection === 'bottom'
}"
>
<!-- Input oculto para selección de archivos -->
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
/>
<!-- Elemento de Texto -->
<div
v-if="element.type === 'text'"
class="w-full h-full flex items-center px-3 py-2 bg-blue-100 rounded border border-blue-300 text-blue-800 text-sm font-medium dark:bg-blue-900/30 dark:border-blue-600 dark:text-blue-300"
>
<input
v-if="isEditing"
ref="editInput"
v-model="editValue"
@blur="finishEditing"
@keydown="handleKeydown"
class="w-full bg-transparent outline-none cursor-text"
@mousedown.stop
/>
<span v-else class="truncate pointer-events-none">
{{ element.content || 'Nuevo texto' }}
</span>
</div>
<!-- Elemento de Imagen -->
<div
v-else-if="element.type === 'image'"
class="w-full h-full flex items-center justify-center bg-gray-100 rounded border border-gray-300 dark:bg-primary/10 dark:border-primary/20 overflow-hidden"
>
<!-- Si hay imagen cargada -->
<img
v-if="element.content && element.content.startsWith('data:image')"
:src="element.content"
:alt="element.fileName || 'Imagen'"
class="w-full h-full object-cover pointer-events-none"
/>
<!-- Placeholder para imagen -->
<div v-else class="flex flex-col items-center justify-center text-gray-400 dark:text-primary-dt/50 p-4">
<GoogleIcon name="image" class="text-2xl mb-1" />
<span class="text-xs text-center">Haz doble clic para cargar imagen</span>
</div>
</div>
<!-- Elemento de Código -->
<div
v-else-if="element.type === 'code'"
class="w-full h-full bg-gray-900 rounded border overflow-hidden"
>
<div class="w-full h-6 bg-gray-800 flex items-center px-2 text-xs text-gray-400 border-b border-gray-700">
<div class="flex gap-1">
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<div class="w-2 h-2 bg-yellow-500 rounded-full"></div>
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
</div>
<span class="ml-2">{{ element.fileName || 'script.js' }}</span>
</div>
<div class="p-2 h-[calc(100%-24px)]">
<textarea
v-if="isEditing"
ref="editTextarea"
v-model="editValue"
@blur="finishEditing"
@keydown="handleKeydown"
class="w-full h-full bg-transparent text-green-400 text-xs font-mono outline-none resize-none cursor-text"
@mousedown.stop
/>
<pre v-else class="text-green-400 text-xs font-mono overflow-auto h-full pointer-events-none whitespace-pre-wrap">{{ element.content || 'console.log("Hola mundo");' }}</pre>
</div>
</div>
<!-- Elemento de Tabla -->
<div
v-else-if="element.type === 'table'"
class="w-full h-full bg-white rounded border overflow-hidden"
>
<div v-if="element.content && element.content.data" class="w-full h-full">
<table class="w-full h-full text-xs border-collapse">
<thead v-if="element.content.data.length > 0">
<tr class="bg-blue-50 dark:bg-blue-900/20">
<th
v-for="(header, colIndex) in element.content.data[0]"
:key="colIndex"
class="border border-gray-300 dark:border-primary/20 px-1 py-1 text-left font-semibold text-blue-800 dark:text-blue-300"
>
<input
v-if="isEditing"
v-model="editValue.data[0][colIndex]"
class="w-full bg-transparent outline-none text-xs"
@mousedown.stop
@click.stop
@focus.stop
/>
<span v-else class="truncate">{{ header }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in element.content.data.slice(1)"
:key="rowIndex"
class="hover:bg-gray-50 dark:hover:bg-primary/5"
>
<td
v-for="(cell, colIndex) in row"
:key="colIndex"
class="border border-gray-300 dark:border-primary/20 px-1 py-1"
>
<input
v-if="isEditing"
v-model="editValue.data[rowIndex + 1][colIndex]"
class="w-full bg-transparent outline-none text-xs"
@mousedown.stop
@click.stop
@focus.stop
/>
<span v-else class="truncate text-gray-700 dark:text-primary-dt">{{ cell }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Placeholder para tabla vacía -->
<div v-else class="flex flex-col items-center justify-center text-gray-400 dark:text-primary-dt/50 p-4">
<GoogleIcon name="table_chart" class="text-2xl mb-1" />
<span class="text-xs text-center">Doble clic para editar tabla</span>
</div>
</div>
<!-- Controles del elemento -->
<div
v-if="isSelected && !isEditing"
class="absolute -top-8 right-0 flex gap-1 opacity-100 transition-opacity z-10"
>
<!-- Indicador de tamaño -->
<div class="px-2 py-1 bg-gray-800 text-white text-xs rounded shadow-sm pointer-events-none">
{{ Math.round(element.width || 200) }} × {{ Math.round(element.height || 40) }}
</div>
<!-- Botón para cargar imagen (solo para elementos de imagen) -->
<button
v-if="element.type === 'image'"
@click.stop="() => fileInput.click()"
class="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs flex items-center justify-center transition-colors shadow-sm"
title="Cargar imagen"
>
<GoogleIcon name="upload" class="text-xs" />
</button>
<!-- Botón eliminar -->
<button
@click.stop="handleDelete"
class="w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded text-xs flex items-center justify-center transition-colors shadow-sm"
title="Eliminar"
>
<GoogleIcon name="close" class="text-xs" />
</button>
</div>
<!-- Controles de redimensionamiento mejorados -->
<div v-if="isSelected && !isEditing" class="absolute inset-0 pointer-events-none">
<!-- Esquina inferior derecha -->
<div
@mousedown.stop="startResize"
class="absolute -bottom-1 -right-1 w-2 h-2 bg-blue-500 border border-white cursor-se-resize pointer-events-auto rounded-sm resize-handle-corner"
title="Redimensionar"
></div>
<!-- Lado derecho -->
<div
@mousedown.stop="(event) => startResizeEdge(event, 'right')"
class="absolute top-1 bottom-1 -right-0.5 w-1 bg-blue-500 opacity-0 cursor-e-resize pointer-events-auto resize-handle-edge"
title="Redimensionar ancho"
></div>
<!-- Lado inferior -->
<div
@mousedown.stop="(event) => startResizeEdge(event, 'bottom')"
class="absolute -bottom-0.5 left-1 right-1 h-1 bg-blue-500 opacity-0 cursor-s-resize pointer-events-auto resize-handle-edge"
title="Redimensionar alto"
></div>
<!-- Puntos de agarre visuales en los bordes (solo visuales) -->
<div class="absolute top-1/2 -right-0.5 w-1 h-6 -translate-y-1/2 bg-blue-500 opacity-30 rounded-full pointer-events-none resize-handle-visual"></div>
<div class="absolute -bottom-0.5 left-1/2 w-6 h-1 -translate-x-1/2 bg-blue-500 opacity-30 rounded-full pointer-events-none resize-handle-visual"></div>
</div>
<!-- Indicador de arrastre -->
<div
v-if="isDragging"
class="absolute inset-0 bg-blue-500 opacity-20 rounded pointer-events-none"
></div>
<!-- Indicador de redimensionamiento -->
<div
v-if="isResizing"
class="absolute inset-0 bg-green-500 opacity-20 rounded pointer-events-none"
></div>
<!-- Tooltip con instrucciones -->
<div
v-if="isSelected && !element.content && element.type !== 'image' && !isResizing"
class="absolute -bottom-8 left-0 bg-gray-900 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10"
>
{{ element.type === 'text' ? 'Doble clic para editar texto' : 'Doble clic para editar código' }}
{{ element.type === 'code' ? ' (Ctrl+Enter para guardar)' : '' }}
</div>
<!-- Tooltip con instrucciones de redimensionamiento -->
<div
v-if="isSelected && !isEditing && element.type === 'image' && !element.content && !isResizing"
class="absolute -bottom-8 left-0 bg-gray-900 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10"
>
Arrastra las esquinas para redimensionar
</div>
<!-- Botón para terminar edición de tabla -->
<div
v-if="isEditing && element.type === 'table'"
class="absolute -bottom-10 left-0 flex gap-2 z-20"
>
<button
@click="finishEditing"
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded shadow-sm transition-colors"
>
Guardar (Ctrl+Enter)
</button>
<button
@click="() => { isEditing = false; editValue = JSON.parse(JSON.stringify(element.content)); }"
class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-xs rounded shadow-sm transition-colors"
>
Cancelar (Esc)
</button>
</div>
</div>
</template>
<style scoped>
/* Estilos para los controles de redimensionamiento mejorados */
.resize-handle-corner {
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.resize-handle-corner:hover {
background-color: #2563eb;
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.resize-handle-edge {
transition: all 0.2s ease;
}
.resize-handle-edge:hover {
opacity: 0.7 !important;
background-color: #2563eb;
}
.resize-handle-visual {
transition: all 0.2s ease;
}
/* Efecto hover para los indicadores visuales */
.group:hover .resize-handle-visual {
opacity: 0.6 !important;
}
.group:hover .resize-handle-edge {
opacity: 0.4 !important;
}
/* Prevenir selección de texto durante el redimensionamiento */
.select-none {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Animación suave para los controles */
@keyframes pulse-resize {
0%, 100% {
opacity: 0.3;
}
50% {
opacity: 0.7;
}
}
.group:hover .resize-handle-visual {
animation: pulse-resize 2s infinite;
}
</style>

View File

@ -0,0 +1,54 @@
<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">
<!-- Nombre del Cliente (todos los documentos) -->
<div>
<span class="font-semibold">Nombre:</span>
{{ data.clienteNombre }}
</div>
<!-- Domicilio (todos los documentos usan el mismo campo) -->
<div>
<span class="font-semibold">Domicilio:</span>
{{ data.clienteDomicilio }}
</div>
<!-- RFC (todos los documentos) -->
<div>
<span class="font-semibold">RFC:</span>
{{ data.clienteRFC }}
</div>
<!-- Teléfono (COTIZACION y REMISION) -->
<div v-if="template.documentType === 'COTIZACION' || template.documentType === 'REMISION'">
<span class="font-semibold">Teléfono:</span>
{{ data.clienteTelefono }}
</div>
<!-- Régimen Fiscal (solo FACTURA) -->
<div v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Régimen Fiscal:</span>
{{ data.clienteRegimen }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,67 @@
<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
v-if="template.documentType === 'REMISION'"
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>RFC: {{ data.empresaRFC }}</p>
<p>{{ data.empresaDireccion }}</p>
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,91 @@
<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: "Folio:",
};
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' || template.documentType === 'REMISION'">
<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-if="template.documentType === 'REMISION'">
<span class="font-semibold">Fecha de Remisión:</span>
{{ data.fechaRemision }}
</p>
<p v-else>
<span class="font-semibold">Fecha de Realización:</span>
{{ data.fechaRealizacion }}
</p>
<p v-if="template.documentType === 'COTIZACION'">
<span class="font-semibold">Vigencia:</span>
{{ data.vigencia }}
</p>
<p v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Tipo de Comprobante:</span>
{{ data.tipoComprobante }}
</p>
</div>
</div>
</div>
</template>

View File

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

View File

@ -0,0 +1,93 @@
<script setup>
import { computed } from 'vue';
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);
};
const isRemision = computed(() => {
return props.template.documentType === 'REMISION';
});
</script>
<template>
<table v-if="isRemision">
<thead>
<tr class="text-white" :style="{ backgroundColor: template.primaryColor }">
<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">CANTIDAD</th>
<th class="border border-white px-1 py-0.5">PRECIO UNIT.</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.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">{{ formatCurrency(producto.precioUnitario) }}</td>
<td class="border border-gray-300 px-1 py-0.5">{{ formatCurrency(producto.cantidad * producto.precioUnitario) }}</td>
</tr>
</tbody>
</table>
<table v-else class="w-full border-collapse mb-3">
<thead>
<tr
class="text-white"
:style="{ backgroundColor: template.primaryColor }"
>
<th class="border border-white px-1 py-0.5">LOTE</th>
<th class="border border-white px-1 py-0.5">CANT</th>
<th class="border border-white px-1 py-0.5">UNIDAD</th>
<th class="border border-white px-1 py-0.5">CODIGO</th>
<th class="border border-white px-1 py-0.5">DESCRIPCION</th>
<th class="border border-white px-1 py-0.5">P. UNIT.</th>
<th class="border border-white px-1 py-0.5">IMPORTE</th>
<th class="border border-white px-1 py-0.5">DESC</th>
<th class="border border-white px-1 py-0.5">TOTAL</th>
</tr>
</thead>
<tbody>
<tr
v-for="producto in productos"
:key="producto.lote"
class="odd:bg-blue-100"
>
<td class="border border-gray-300 px-1 py-0.5 text-center">{{ producto.lote }}</td>
<td class="border border-gray-300 px-1 py-0.5 text-center">{{ producto.cantidad }}</td>
<td class="border border-gray-300 px-1 py-0.5 text-center">{{ producto.unidad || producto.unidadSAT }}</td>
<td class="border border-gray-300 px-1 py-0.5">{{ producto.codigo }}</td>
<td class="border border-gray-300 px-1 py-0.5">{{ producto.descripcion }}</td>
<td class="border border-gray-300 px-1 py-0.5 text-right whitespace-nowrap">
{{ formatCurrency(producto.precioUnitario) }}
</td>
<td class="border border-gray-300 px-1 py-0.5 text-right whitespace-nowrap">
{{ formatCurrency(producto.cantidad * producto.precioUnitario) }}
</td>
<td class="border border-gray-300 px-1 py-0.5 text-right whitespace-nowrap">
{{ formatCurrency(producto.descuento || 0) }}
</td>
<td class="border border-gray-300 px-1 py-0.5 text-right font-semibold whitespace-nowrap">
{{ formatCurrency(producto.cantidad * producto.precioUnitario - (producto.descuento || 0)) }}
</td>
</tr>
</tbody>
</table>
</template>

View File

@ -0,0 +1,259 @@
<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: 'PIEZA', nombre: 'Pieza' },
{ codigo: 'CAJA', nombre: 'Caja' },
{ codigo: 'PAQUETE', nombre: 'Paquete' },
{ codigo: 'KG', nombre: 'Kilogramo' },
{ codigo: 'METRO', nombre: 'Metro' },
{ codigo: 'LITRO', nombre: 'Litro' },
];
/**
* Agregar nuevo producto
*/
const agregarProducto = () => {
productos.value.push({
cantidad: 1,
unidad: 'PIEZA',
codigo: '',
descripcion: '',
precioUnitario: 0,
});
actualizarProductos();
};
/**
* Eliminar producto
*/
const eliminarProducto = (index) => {
productos.value.splice(index, 1);
actualizarProductos();
};
/**
* Calcular importe total
*/
const calcularImporte = (producto) => {
return producto.cantidad * producto.precioUnitario;
};
/**
* Calcular totales generales
*/
const calculos = computed(() => {
const total = productos.value.reduce((sum, producto) => {
return sum + calcularImporte(producto);
}, 0);
return {
total,
subtotal: total,
iva: 0,
descuentoTotal: 0,
};
});
/**
* 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">
<!-- ENCABEZADO -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-primary-dt">
Productos / Servicios
</h3>
<p class="text-xs text-gray-500 dark:text-primary-dt/60">
Listado de productos en la remisión
</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 Producto
</button>
</div>
<!-- PRODUCTOS -->
<div v-if="productos.length > 0" class="space-y-3">
<div
v-for="(producto, index) in productos"
:key="index"
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 -->
<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">
#{{ index + 1 }}
</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 producto"
>
<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"
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 -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Código
</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>
<!-- 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>
</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 <span class="text-red-500">*</span>
</label>
<textarea
v-model="producto.descripcion"
rows="2"
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..."
></textarea>
</div>
<!-- IMPORTE -->
<div class="mt-3 flex justify-end">
<div class="text-right">
<span class="text-xs text-gray-500 dark:text-primary-dt/70">Importe:</span>
<div class="text-lg font-bold text-blue-700 dark:text-blue-400">
{{ formatCurrency(calcularImporte(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="inventory_2" class="text-5xl mb-3 opacity-50" />
<p class="text-sm font-semibold">No hay productos agregados</p>
<p class="text-xs">Haz clic en "Agregar Producto" para comenzar</p>
</div>
<!-- RESUMEN DE TOTALES -->
<div v-if="productos.length > 0" class="mt-6 flex justify-end">
<div class="bg-blue-50 rounded-lg p-4 min-w-[250px] dark:bg-blue-900/20">
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-sm font-semibold text-gray-700 dark:text-primary-dt">
Total:
</span>
<span class="text-xl font-bold text-blue-700 dark:text-blue-400">
{{ formatCurrency(calculos.total) }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

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

View File

@ -1,64 +0,0 @@
<script setup>
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
type: {
type: String,
required: true,
},
icon: String,
title: String,
description: String,
});
/** Eventos */
const emit = defineEmits(['dragstart']);
/** Referencias */
const isDragging = ref(false);
/** Métodos */
const handleDragStart = (event) => {
isDragging.value = true;
event.dataTransfer.setData('text/plain', JSON.stringify({
type: props.type,
title: props.title
}));
emit('dragstart', props.type);
};
const handleDragEnd = () => {
isDragging.value = false;
};
</script>
<template>
<div
draggable="true"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 bg-white cursor-grab hover:bg-gray-50 hover:border-blue-300 transition-colors dark:bg-primary-d dark:border-primary/20 dark:hover:bg-primary/10"
:class="{
'opacity-50 cursor-grabbing': isDragging,
'shadow-sm hover:shadow-md': !isDragging
}"
>
<div class="flex-shrink-0 w-8 h-8 rounded-md bg-blue-100 flex items-center justify-center dark:bg-blue-900/30">
<GoogleIcon
:name="icon"
class="text-blue-600 dark:text-blue-400 text-lg"
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-primary-dt">
{{ title }}
</div>
<div class="text-xs text-gray-500 dark:text-primary-dt/70 truncate">
{{ description }}
</div>
</div>
</div>
</template>

View File

@ -1,252 +1,115 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import { ref, computed, watch } from "vue";
import GoogleIcon from "@Shared/GoogleIcon.vue";
import TiptapEditor from "./TiptapEditor.vue";
/** Propiedades */
const props = defineProps({
element: {
type: Object,
required: true
},
isSelected: {
type: Boolean,
default: false
}
element: { type: Object, required: true },
isSelected: { type: Boolean, default: false },
pageDimensions: { type: Object, required: true },
});
const emit = defineEmits([
"select",
"delete",
"update",
"move",
"editor-active",
"table-editing",
"edit-table",
]);
/** Eventos */
const emit = defineEmits(['select', 'delete', 'update', 'move']);
/** Referencias */
const isEditing = ref(false);
const editValue = ref('');
const editInput = ref(null);
const editTextarea = ref(null);
const elementRef = ref(null);
const isDragging = ref(false);
const isResizing = ref(false);
const resizeDirection = ref(null); // 'corner', 'right', 'bottom'
const dragStart = ref({ x: 0, y: 0 });
const dragStart = ref({ mouseX: 0, mouseY: 0, elementX: 0, elementY: 0 });
const resizeStart = ref({ x: 0, y: 0, width: 0, height: 0 });
const fileInput = ref(null);
/** Propiedades computadas */
const elementStyles = computed(() => {
const baseStyles = {
const elementStyles = computed(() => ({
left: `${props.element.x}px`,
top: `${props.element.y}px`,
width: `${props.element.width || 200}px`,
height: `${props.element.height || 40}px`
};
height: `${props.element.height || 120}px`,
position: "absolute",
zIndex: isEditing.value ? 30 : props.isSelected ? 20 : 10,
}));
// Aplicar estilos de formato para elementos de texto
if (props.element.type === 'text' && props.element.formatting) {
const formatting = props.element.formatting;
if (formatting.fontSize) {
baseStyles.fontSize = `${formatting.fontSize}px`;
}
if (formatting.color) {
baseStyles.color = formatting.color;
watch(
() => props.isSelected,
(selected) => {
if (!selected && isEditing.value) {
isEditing.value = false;
emit("editor-active", null);
if (props.element.type === "table") {
emit("table-editing", false);
}
}
return baseStyles;
});
// Propiedades computadas para clases CSS dinámicas
const textContainerClasses = computed(() => {
if (props.element.type !== 'text') return {};
const formatting = props.element.formatting || {};
return {
'font-bold': formatting.bold,
'italic': formatting.italic,
'underline': formatting.underline,
'text-left': !formatting.textAlign || formatting.textAlign === 'left',
'text-center': formatting.textAlign === 'center',
'text-right': formatting.textAlign === 'right',
'justify-start': !formatting.textAlign || formatting.textAlign === 'left',
'justify-center': formatting.textAlign === 'center',
'justify-end': formatting.textAlign === 'right'
};
});
const inputClasses = computed(() => {
if (props.element.type !== 'text') return {};
const formatting = props.element.formatting || {};
return {
'font-bold': formatting.bold,
'italic': formatting.italic,
'underline': formatting.underline,
'text-left': !formatting.textAlign || formatting.textAlign === 'left',
'text-center': formatting.textAlign === 'center',
'text-right': formatting.textAlign === 'right'
};
});
const inputStyles = computed(() => {
if (props.element.type !== 'text') return {};
const formatting = props.element.formatting || {};
const styles = {};
if (formatting.fontSize) {
styles.fontSize = `${formatting.fontSize}px`;
}
);
if (formatting.color) {
styles.color = formatting.color;
}
return styles;
});
/** Watchers */
watch(() => props.isSelected, (selected) => {
if (selected && isEditing.value) {
nextTick(() => {
if (props.element.type === 'text' && editInput.value) {
editInput.value.focus();
editInput.value.select();
} else if (props.element.type === 'code' && editTextarea.value) {
editTextarea.value.focus();
editTextarea.value.select();
}
});
}
});
/** Métodos */
const handleSelect = (event) => {
event.stopPropagation();
emit('select', props.element.id);
};
const handleDelete = () => {
emit('delete', props.element.id);
emit("select", props.element.id);
};
const startEditing = () => {
if (props.element.type === 'table' && props.element.content) {
// Deep copy para evitar mutaciones directas
editValue.value = JSON.parse(JSON.stringify(props.element.content));
} else if (props.element.type === 'code') {
editValue.value = props.element.content || 'console.log("Hola mundo");';
} else {
editValue.value = props.element.content || getDefaultEditValue();
}
if (!props.isSelected) emit("select", props.element.id);
if (props.element.type === "text") {
isEditing.value = true;
nextTick(() => {
if (editTextarea.value) editTextarea.value.focus();
if (editInput.value) editInput.value.focus();
});
};
const finishEditing = () => {
if (isEditing.value) {
isEditing.value = false;
// Para tablas, emitir el objeto completo
if (props.element.type === 'table') {
emit('update', {
id: props.element.id,
content: editValue.value
});
} else {
emit('update', {
id: props.element.id,
content: editValue.value
});
}
if (props.element.type === "table") {
// Emitir evento para abrir modal
emit("edit-table", props.element.id);
}
if (props.element.type === "image") {
fileInput.value.click();
}
};
const handleKeydown = (event) => {
if (props.element.type === 'text') {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
finishEditing();
} else if (event.key === 'Escape') {
isEditing.value = false;
editValue.value = props.element.content || 'Nuevo texto';
}
} else if (props.element.type === 'code') {
if (event.key === 'Escape') {
isEditing.value = false;
editValue.value = props.element.content || 'console.log("Hola mundo");';
}
// Para código, permitimos Enter normal y usamos Ctrl+Enter para terminar
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
finishEditing();
}
} else if (props.element.type === 'table') {
if (event.key === 'Escape') {
isEditing.value = false;
// Restaurar el contenido original de la tabla
editValue.value = props.element.content ?
JSON.parse(JSON.stringify(props.element.content)) :
getDefaultEditValue();
}
// Para tablas, Enter normal para nueva línea en celda, Ctrl+Enter para terminar
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
finishEditing();
}
}
const handleContentUpdate = (newContent) => {
emit("update", { id: props.element.id, content: newContent });
};
// Manejo de archivo de imagen
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
emit('update', {
id: props.element.id,
content: e.target.result,
fileName: file.name
});
};
reader.readAsDataURL(file);
}
// Limpiar el input
event.target.value = '';
const handleEditorFocus = (editor) => {
emit("editor-active", editor);
};
// Funcionalidad de arrastre
const handleMouseDown = (event) => {
if (isEditing.value || isResizing.value) return;
if (isEditing.value || event.target.closest(".resize-handle")) return;
event.preventDefault();
if (!props.isSelected) emit("select", props.element.id);
isDragging.value = true;
dragStart.value = {
x: event.clientX - props.element.x,
y: event.clientY - props.element.y
mouseX: event.clientX,
mouseY: event.clientY,
elementX: props.element.x,
elementY: props.element.y,
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
event.preventDefault();
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
const handleMouseMove = (event) => {
if (isDragging.value && !isResizing.value) {
const newX = event.clientX - dragStart.value.x;
const newY = event.clientY - dragStart.value.y;
if (isDragging.value) {
const deltaX = event.clientX - dragStart.value.mouseX;
const deltaY = event.clientY - dragStart.value.mouseY;
let newX = dragStart.value.elementX + deltaX;
let newY = dragStart.value.elementY + deltaY;
emit('move', {
id: props.element.id,
x: Math.max(0, newX),
y: Math.max(0, newY)
});
} else if (isResizing.value && !isDragging.value) {
// Límites
const pageW = props.pageDimensions.width;
const pageH = props.pageDimensions.height;
const elW = props.element.width;
const elH = props.element.height;
newX = Math.max(0, Math.min(newX, pageW - elW));
newY = Math.max(0, Math.min(newY, pageH - elH));
emit("move", { id: props.element.id, x: newX, y: newY });
} else if (isResizing.value) {
handleResizeMove(event);
}
};
@ -254,130 +117,144 @@ const handleMouseMove = (event) => {
const handleMouseUp = () => {
isDragging.value = false;
isResizing.value = false;
resizeDirection.value = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
// Funcionalidad de redimensionamiento por esquina
const startResize = (event) => {
event.stopPropagation();
event.preventDefault();
if (isEditing.value) return;
isResizing.value = true;
resizeDirection.value = 'corner';
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: props.element.width || 200,
height: props.element.height || 40
width: elementRef.value.offsetWidth,
height: elementRef.value.offsetHeight,
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// Funcionalidad de redimensionamiento por bordes
const startResizeEdge = (event, direction) => {
event.stopPropagation();
event.preventDefault();
isResizing.value = true;
resizeDirection.value = direction;
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: props.element.width || 200,
height: props.element.height || 40
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
const handleResizeMove = (event) => {
if (!isResizing.value) return;
const deltaX = event.clientX - resizeStart.value.x;
const deltaY = event.clientY - resizeStart.value.y;
let newWidth = resizeStart.value.width;
let newHeight = resizeStart.value.height;
// Límites
const pageW = props.pageDimensions.width;
const pageH = props.pageDimensions.height;
const elX = props.element.x;
const elY = props.element.y;
// Calcular nuevas dimensiones según la dirección
if (resizeDirection.value === 'corner') {
newWidth = Math.max(getMinWidth(), Math.min(getMaxWidth(), resizeStart.value.width + deltaX));
newHeight = Math.max(getMinHeight(), Math.min(getMaxHeight(), resizeStart.value.height + deltaY));
} else if (resizeDirection.value === 'right') {
newWidth = Math.max(getMinWidth(), Math.min(getMaxWidth(), resizeStart.value.width + deltaX));
} else if (resizeDirection.value === 'bottom') {
newHeight = Math.max(getMinHeight(), Math.min(getMaxHeight(), resizeStart.value.height + deltaY));
}
let newWidth = resizeStart.value.width + deltaX;
let newHeight = resizeStart.value.height + deltaY;
emit('update', {
newWidth = Math.max(100, Math.min(newWidth, pageW - elX));
newHeight = Math.max(40, Math.min(newHeight, pageH - elY));
emit("update", { id: props.element.id, width: newWidth, height: newHeight });
};
const handleDelete = () => emit("delete", props.element.id);
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file && file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = (e) =>
emit("update", {
id: props.element.id,
width: newWidth,
height: newHeight
content: e.target.result,
fileName: file.name,
});
};
// Obtener tamaños mínimos según el tipo de elemento
const getMinWidth = () => {
switch (props.element.type) {
case 'text':
return 100;
case 'image':
return 100;
case 'table':
return 200;
default:
return 100;
reader.readAsDataURL(file);
}
event.target.value = null;
};
const getMinHeight = () => {
switch (props.element.type) {
case 'text':
return 30;
case 'image':
return 80;
case 'table':
return 80;
default:
return 30;
}
};
// Obtener tamaños máximos según el tipo de elemento
const getMaxWidth = () => {
return 800; // Máximo general
};
const getMaxHeight = () => {
return 600; // Máximo general
};
</script>
<template>
<div
ref="elementRef"
:style="elementStyles"
class="group select-none bg-white border border-gray-300 rounded transition-shadow"
:class="{
'ring-2 ring-blue-500 border-blue-500 shadow-md': isSelected,
'overflow-hidden': element.type === 'image',
}"
@click="handleSelect"
@dblclick="startEditing"
@mousedown="handleMouseDown"
class="absolute group select-none"
:class="{
'ring-2 ring-blue-500 ring-opacity-50': isSelected,
'cursor-move': !isEditing && !isResizing,
'cursor-text': isEditing && (element.type === 'text' || element.type === 'code'),
'cursor-se-resize': isResizing && resizeDirection === 'corner',
'cursor-e-resize': isResizing && resizeDirection === 'right',
'cursor-s-resize': isResizing && resizeDirection === 'bottom',
'z-50': isSelected,
'z-10': !isSelected
}"
>
<!-- Input oculto para selección de archivos -->
<div
v-if="!isEditing"
@mousedown="handleMouseDown"
class="absolute inset-0 z-10 cursor-move"
/>
<div
v-if="element.type === 'text'"
class="w-full h-full overflow-hidden"
>
<TiptapEditor
class="w-full h-full overflow-auto"
:model-value="element.content"
:editable="isEditing"
@update:model-value="handleContentUpdate"
@focus="handleEditorFocus"
/>
</div>
<div
v-else-if="element.type === 'image'"
class="w-full h-full flex items-center justify-center bg-gray-50"
>
<img
v-if="element.content"
:src="element.content"
class="w-full h-full object-cover rounded"
:alt="element.fileName || 'Imagen'"
/>
<div v-else class="text-gray-400 text-center p-4">
<GoogleIcon name="image" class="text-3xl mb-2" />
<p class="text-xs">Doble clic para subir</p>
</div>
</div>
<div
v-else-if="element.type === 'table'"
class="w-full h-full overflow-auto bg-white p-2"
>
<div v-html="element.content" class="table-preview"></div>
<div
v-if="!element.content || element.content.includes('<!---->')"
class="flex items-center justify-center h-full text-gray-400"
>
<div class="text-center">
<GoogleIcon name="table_chart" class="text-4xl mb-2" />
<p class="text-sm">Doble clic para editar tabla</p>
</div>
</div>
</div>
<div
v-if="isSelected && !isEditing"
class="absolute -top-8 right-0 flex items-center gap-1 z-20"
>
<button
@click.stop="handleDelete"
class="w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded flex items-center justify-center text-xs font-bold transition-colors"
title="Eliminar"
>
x
</button>
</div>
<div
v-if="isSelected && !isEditing"
@mousedown.stop="startResize"
class="resize-handle absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border-2 border-white cursor-se-resize rounded-full z-20 hover:bg-blue-600 transition-colors"
/>
<input
ref="fileInput"
type="file"
@ -385,266 +262,30 @@ const getMaxHeight = () => {
@change="handleFileSelect"
class="hidden"
/>
<!-- Elemento de Texto con formato aplicado -->
<div
v-if="element.type === 'text'"
class="w-full h-full flex items-center px-3 py-2 bg-white rounded border border-gray-300 shadow-sm dark:bg-white dark:border-gray-400"
:class="textContainerClasses"
:style="{
fontSize: element.formatting?.fontSize ? `${element.formatting.fontSize}px` : '14px',
color: element.formatting?.color || '#374151'
}"
>
<input
v-if="isEditing"
ref="editInput"
v-model="editValue"
@blur="finishEditing"
@keydown="handleKeydown"
class="w-full bg-transparent outline-none cursor-text"
:class="inputClasses"
:style="inputStyles"
@mousedown.stop
/>
<span
v-else
class="truncate pointer-events-none w-full"
:class="textContainerClasses"
:style="{
fontSize: element.formatting?.fontSize ? `${element.formatting.fontSize}px` : '14px',
color: element.formatting?.color || '#374151'
}"
>
{{ element.content || 'Nuevo texto' }}
</span>
</div>
<!-- Elemento de Imagen (sin cambios) -->
<div
v-else-if="element.type === 'image'"
class="w-full h-full flex items-center justify-center bg-gray-100 rounded border border-gray-300 dark:bg-primary/10 dark:border-primary/20 overflow-hidden"
>
<!-- Si hay imagen cargada -->
<img
v-if="element.content && element.content.startsWith('data:image')"
:src="element.content"
:alt="element.fileName || 'Imagen'"
class="w-full h-full object-cover pointer-events-none"
/>
<!-- Placeholder para imagen -->
<div v-else class="flex flex-col items-center justify-center text-gray-400 dark:text-primary-dt/50 p-4">
<GoogleIcon name="image" class="text-2xl mb-1" />
<span class="text-xs text-center">Haz doble clic para cargar imagen</span>
</div>
</div>
<!-- Elemento de Tabla (sin cambios en esta parte) -->
<div
v-else-if="element.type === 'table'"
class="w-full h-full bg-white rounded border overflow-hidden"
>
<div v-if="element.content && element.content.data" class="w-full h-full">
<table class="w-full h-full text-xs border-collapse">
<thead v-if="element.content.data.length > 0">
<tr class="bg-blue-50 dark:bg-blue-900/20">
<th
v-for="(header, colIndex) in element.content.data[0]"
:key="colIndex"
class="border border-gray-300 dark:border-primary/20 px-1 py-1 text-left font-semibold text-blue-800 dark:text-blue-300"
>
<input
v-if="isEditing"
v-model="editValue.data[0][colIndex]"
class="w-full bg-transparent outline-none text-xs"
@mousedown.stop
@click.stop
@focus.stop
/>
<span v-else class="truncate">{{ header }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in element.content.data.slice(1)"
:key="rowIndex"
class="hover:bg-gray-50 dark:hover:bg-primary/5"
>
<td
v-for="(cell, colIndex) in row"
:key="colIndex"
class="border border-gray-300 dark:border-primary/20 px-1 py-1"
>
<input
v-if="isEditing"
v-model="editValue.data[rowIndex + 1][colIndex]"
class="w-full bg-transparent outline-none text-xs"
@mousedown.stop
@click.stop
@focus.stop
/>
<span v-else class="truncate text-gray-700 dark:text-primary-dt">{{ cell }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Placeholder para tabla vacía -->
<div v-else class="flex flex-col items-center justify-center text-gray-400 dark:text-primary-dt/50 p-4">
<GoogleIcon name="table_chart" class="text-2xl mb-1" />
<span class="text-xs text-center">Doble clic para editar tabla</span>
</div>
</div>
<!-- Controles del elemento con z-index más alto -->
<div
v-if="isSelected && !isEditing"
class="absolute -top-8 right-0 flex gap-1 opacity-100 transition-opacity z-[60]"
>
<!-- Indicador de tamaño -->
<div class="px-2 py-1 bg-gray-800 text-white text-xs rounded shadow-sm pointer-events-none">
{{ Math.round(element.width || 200) }} × {{ Math.round(element.height || 40) }}
</div>
<!-- Botón para cargar imagen (solo para elementos de imagen) -->
<button
v-if="element.type === 'image'"
@click.stop="() => fileInput.click()"
class="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs flex items-center justify-center transition-colors shadow-sm"
title="Cargar imagen"
>
<GoogleIcon name="upload" class="text-xs" />
</button>
<!-- Botón eliminar -->
<button
@click.stop="handleDelete"
class="w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded text-xs flex items-center justify-center transition-colors shadow-sm"
title="Eliminar"
>
<GoogleIcon name="close" class="text-xs" />
</button>
</div>
<!-- Controles de redimensionamiento mejorados -->
<div v-if="isSelected && !isEditing" class="absolute inset-0 pointer-events-none z-[55]">
<!-- Esquina inferior derecha - MÁS GRANDE Y VISIBLE -->
<div
@mousedown.stop="startResize"
class="absolute -bottom-2 -right-2 w-4 h-4 bg-blue-500 border-2 border-white cursor-se-resize pointer-events-auto rounded-sm shadow-md hover:bg-blue-600 transition-all"
title="Redimensionar"
>
<div class="absolute inset-0.5 bg-white/30 rounded-sm"></div>
</div>
<!-- Lado derecho - MÁS VISIBLE -->
<div
@mousedown.stop="(event) => startResizeEdge(event, 'right')"
class="absolute top-2 bottom-2 -right-1 w-2 bg-blue-500 cursor-e-resize pointer-events-auto rounded-sm shadow-sm hover:bg-blue-600 transition-all"
title="Redimensionar ancho"
>
<!-- Indicador visual en el centro -->
<div class="absolute top-1/2 left-1/2 w-0.5 h-4 bg-white/60 -translate-x-1/2 -translate-y-1/2 rounded-full"></div>
</div>
<!-- Lado inferior - MÁS VISIBLE -->
<div
@mousedown.stop="(event) => startResizeEdge(event, 'bottom')"
class="absolute -bottom-1 left-2 right-2 h-2 bg-blue-500 cursor-s-resize pointer-events-auto rounded-sm shadow-sm hover:bg-blue-600 transition-all"
title="Redimensionar alto"
>
<!-- Indicador visual en el centro -->
<div class="absolute top-1/2 left-1/2 w-4 h-0.5 bg-white/60 -translate-x-1/2 -translate-y-1/2 rounded-full"></div>
</div>
<!-- Esquinas adicionales para mejor UX -->
<div
@mousedown.stop="startResize"
class="absolute -top-2 -left-2 w-4 h-4 bg-blue-500 border-2 border-white cursor-nw-resize pointer-events-auto rounded-sm shadow-md hover:bg-blue-600 transition-all"
title="Redimensionar"
>
<div class="absolute inset-0.5 bg-white/30 rounded-sm"></div>
</div>
<div
@mousedown.stop="startResize"
class="absolute -top-2 -right-2 w-4 h-4 bg-blue-500 border-2 border-white cursor-ne-resize pointer-events-auto rounded-sm shadow-md hover:bg-blue-600 transition-all"
title="Redimensionar"
>
<div class="absolute inset-0.5 bg-white/30 rounded-sm"></div>
</div>
<div
@mousedown.stop="startResize"
class="absolute -bottom-2 -left-2 w-4 h-4 bg-blue-500 border-2 border-white cursor-sw-resize pointer-events-auto rounded-sm shadow-md hover:bg-blue-600 transition-all"
title="Redimensionar"
>
<div class="absolute inset-0.5 bg-white/30 rounded-sm"></div>
</div>
</div>
<!-- Indicador de arrastre -->
<div
v-if="isDragging"
class="absolute inset-0 bg-blue-500 opacity-20 rounded pointer-events-none"
></div>
<!-- Indicador de redimensionamiento -->
<div
v-if="isResizing"
class="absolute inset-0 bg-green-500 opacity-20 rounded pointer-events-none"
></div>
<!-- Botón para terminar edición de tabla -->
<div
v-if="isEditing && element.type === 'table'"
class="absolute -bottom-10 left-0 flex gap-2 z-[60]"
>
<button
@click="finishEditing"
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded shadow-sm transition-colors"
>
Guardar
</button>
<button
@click="() => { isEditing = false; editValue = JSON.parse(JSON.stringify(element.content)); }"
class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-xs rounded shadow-sm transition-colors"
>
Cancelar
</button>
</div>
</div>
</template>
<style scoped>
/* Estilos existentes sin cambios... */
.resize-handle-corner {
transition: all 0.2s ease;
/* Estilos para vista previa de tabla */
:deep(.table-preview table) {
border-collapse: collapse;
width: 100%;
font-size: 0.875rem;
}
.resize-handle-corner:hover {
transform: scale(1.1);
:deep(.table-preview td),
:deep(.table-preview th) {
border: 1px solid #d1d5db;
padding: 0.5rem;
text-align: left;
}
.resize-handle-edge {
transition: all 0.2s ease;
opacity: 0.7;
:deep(.table-preview th) {
background-color: #f3f4f6;
font-weight: 600;
}
.resize-handle-edge:hover {
opacity: 1;
transform: scale(1.05);
}
.group:hover .resize-handle-edge {
opacity: 0.8;
}
.select-none {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
:deep(.table-preview p) {
margin: 0;
}
</style>

View File

@ -2,31 +2,17 @@
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
type: {
type: String,
required: true,
},
icon: String,
title: String,
description: String,
type: { type: String, required: true },
icon: { type: String, required: true },
title: { type: String, required: true },
});
/** Eventos */
const emit = defineEmits(['dragstart']);
/** Referencias */
const isDragging = ref(false);
/** Métodos */
const handleDragStart = (event) => {
isDragging.value = true;
event.dataTransfer.setData('text/plain', JSON.stringify({
type: props.type,
title: props.title
}));
emit('dragstart', props.type);
event.dataTransfer.setData('text/plain', JSON.stringify({ type: props.type }));
};
const handleDragEnd = () => {
@ -39,26 +25,23 @@ const handleDragEnd = () => {
draggable="true"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 bg-white cursor-grab hover:bg-gray-50 hover:border-blue-300 transition-colors dark:bg-primary-d dark:border-primary/20 dark:hover:bg-primary/10"
class="flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border border-gray-200 bg-white cursor-grab hover:bg-gray-50 hover:border-blue-300 transition-colors"
:class="{
'opacity-50 cursor-grabbing': isDragging,
'shadow-sm hover:shadow-md': !isDragging
}"
>
<div class="flex-shrink-0 w-8 h-8 rounded-md bg-blue-100 flex items-center justify-center dark:bg-blue-900/30">
<div class="flex-shrink-0 w-6 h-6 sm:w-8 sm:h-8 rounded-md bg-blue-100 flex items-center justify-center">
<GoogleIcon
:name="icon"
class="text-blue-600 dark:text-blue-400 text-lg"
class="text-blue-600 text-sm sm:text-lg"
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-primary-dt">
<div class="text-xs sm:text-sm font-medium text-gray-900">
{{ title }}
</div>
<div class="text-xs text-gray-500 dark:text-primary-dt/70 truncate">
{{ description }}
</div>
</div>
</div>
</template>

View File

@ -5,349 +5,138 @@ import PageSizeSelector from '@Holos/PDF/PageSizeSelector.vue';
/** Propiedades */
const props = defineProps({
pages: {
type: Array,
default: () => [{ id: 1, elements: [] }]
},
selectedElementId: String,
isExporting: Boolean
pages: { type: Array, default: () => [] },
currentPage: { type: Number, default: 1 }
});
/** Eventos */
const emit = defineEmits(['drop', 'dragover', 'click', 'add-page', 'delete-page', 'page-change', 'page-size-change']);
const emit = defineEmits(['drop', 'add-page', 'delete-page', 'page-change', 'page-size-change', 'click']);
/** Referencias */
const viewportRef = ref(null);
const currentPage = ref(1);
const pageSize = ref('A4');
/** Tamaños de página */
const pageSizes = {
'A4': { width: 794, height: 1123, label: '210 × 297 mm' },
'A3': { width: 1123, height: 1587, label: '297 × 420 mm' },
'Letter': { width: 816, height: 1056, label: '216 × 279 mm' },
'Legal': { width: 816, height: 1344, label: '216 × 356 mm' },
'Tabloid': { width: 1056, height: 1632, label: '279 × 432 mm' }
'A4': { width: 794, height: 1123, label: 'A4 (210 x 297 mm)' },
'A3': { width: 1123, height: 1587, label: 'A3 (297 x 420 mm)' },
'Tabloid': { width: 1056, height: 1632, label: 'Tabloide (279 x 432 mm)' }
};
/** Constantes de diseño ajustadas */
const PAGE_MARGIN = 50;
const ZOOM_LEVEL = 0.65;
const ZOOM_LEVEL = 1.0;
/** Propiedades computadas */
const currentPageSize = computed(() => pageSizes[pageSize.value]);
const PAGE_WIDTH = computed(() => currentPageSize.value.width);
const PAGE_HEIGHT = computed(() => currentPageSize.value.height);
const scaledPageWidth = computed(() => PAGE_WIDTH.value * ZOOM_LEVEL);
const scaledPageHeight = computed(() => PAGE_HEIGHT.value * ZOOM_LEVEL);
const currentPageSize = computed(() => pageSizes[pageSize.value] || pageSizes['A4']);
const scaledPageWidth = computed(() => currentPageSize.value.width * ZOOM_LEVEL);
const scaledPageHeight = computed(() => currentPageSize.value.height * ZOOM_LEVEL);
const totalPages = computed(() => props.pages.length);
/** Watchers */
watch(pageSize, (newSize) => {
emit('page-size-change', {
size: newSize,
dimensions: pageSizes[newSize]
});
emit('page-size-change', { size: newSize, dimensions: pageSizes[newSize] });
});
/** Métodos */
const handleDrop = (event, pageIndex) => {
event.preventDefault();
const pageElement = event.currentTarget;
const rect = pageElement.getBoundingClientRect();
const relativeX = (event.clientX - rect.left) / ZOOM_LEVEL;
const relativeY = (event.clientY - rect.top) / ZOOM_LEVEL;
emit('drop', {
originalEvent: event,
pageIndex,
x: Math.max(0, Math.min(PAGE_WIDTH.value, relativeX)),
y: Math.max(0, Math.min(PAGE_HEIGHT.value, relativeY))
});
const rect = event.currentTarget.getBoundingClientRect();
const x = (event.clientX - rect.left) / ZOOM_LEVEL;
const y = (event.clientY - rect.top) / ZOOM_LEVEL;
emit('drop', { originalEvent: event, pageIndex, x, y });
};
const handleDragOver = (event) => {
event.preventDefault();
emit('dragover', event);
};
const handleClick = (event, pageIndex) => {
if (event.target.classList.contains('pdf-page')) {
emit('click', { originalEvent: event, pageIndex });
}
const setCurrentPage = (pageNumber) => {
emit('page-change', pageNumber);
};
const addPageAndNavigate = () => {
emit('add-page');
nextTick(() => {
setCurrentPage(totalPages.value);
});
};
const handleNextPage = () => {
if (currentPage.value >= totalPages.value) {
addPage();
if (props.currentPage >= totalPages.value) {
addPageAndNavigate();
} else {
setCurrentPage(currentPage.value + 1);
setCurrentPage(props.currentPage + 1);
}
};
const addPage = () => {
emit('add-page');
// Solo cambiar a la nueva página cuando se agrega una
nextTick(() => {
const newPageNumber = totalPages.value + 1;
setCurrentPage(newPageNumber);
});
};
const deletePage = (pageIndex) => {
if (totalPages.value > 1) {
emit('delete-page', pageIndex);
}
};
const scrollToPage = (pageNumber) => {
if (viewportRef.value) {
const pageElement = viewportRef.value.querySelector(`[data-page="${pageNumber}"]`);
if (pageElement) {
pageElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
}
};
const setCurrentPage = (pageNumber) => {
currentPage.value = pageNumber;
emit('page-change', pageNumber);
// Mantener la página actual centrada
nextTick(() => {
scrollToPage(pageNumber);
});
};
/** Métodos expuestos */
defineExpose({
scrollToPage,
setCurrentPage,
PAGE_WIDTH,
PAGE_HEIGHT,
ZOOM_LEVEL
});
</script>
<template>
<div class="flex-1 flex flex-col bg-gray-100 dark:bg-primary-d/20">
<!-- Toolbar de páginas -->
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-primary-d border-b border-gray-200 dark:border-primary/20">
<div class="flex-1 flex flex-col bg-gray-100">
<div class="flex items-center justify-between px-4 py-3 bg-white border-b">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-gray-700 dark:text-primary-dt">
<span class="text-sm font-medium text-gray-700">
Página {{ currentPage }} de {{ totalPages }}
</span>
<div class="flex items-center gap-1 border-l border-gray-200 dark:border-primary/20 pl-4">
<div class="flex items-center gap-1 border-l pl-4">
<button
@click="setCurrentPage(Math.max(1, currentPage - 1))"
:disabled="currentPage <= 1"
class="p-1.5 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt rounded hover:bg-gray-100 dark:hover:bg-primary/10"
class="p-2 text-gray-500 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
title="Página anterior"
>
<GoogleIcon name="keyboard_arrow_left" class="text-lg" />
<GoogleIcon name="keyboard_arrow_left" class="text-xl" />
</button>
<button
@click="handleNextPage"
:disabled="isExporting"
class="p-1.5 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt rounded hover:bg-gray-100 dark:hover:bg-primary/10 relative"
class="p-2 text-gray-500 hover:text-gray-700"
:title="currentPage >= totalPages ? 'Crear nueva página' : 'Página siguiente'"
>
<GoogleIcon name="keyboard_arrow_right" class="text-lg" />
<!-- Indicador solo cuando estamos en la última página -->
<GoogleIcon
v-if="currentPage >= totalPages"
name="add"
class="absolute -top-1 -right-1 text-xs text-green-500 bg-white rounded-full"
/>
<GoogleIcon name="keyboard_arrow_right" class="text-xl" />
</button>
</div>
</div>
<div class="flex items-center gap-4">
<!-- Selector de tamaño de página -->
<div class="flex-shrink-0">
<PageSizeSelector v-model="pageSize" />
<span class="text-xs text-gray-500 dark:text-primary-dt/70 bg-gray-50 dark:bg-primary/10 px-2 py-1 rounded">
{{ Math.round(ZOOM_LEVEL * 100) }}% {{ currentPageSize.label }}
</span>
<button
@click="addPage"
:disabled="isExporting"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 font-medium"
>
<GoogleIcon name="add" class="text-sm" />
Nueva Página
</button>
</div>
</div>
<!-- Viewport de páginas horizontal -->
<div
ref="viewportRef"
class="flex-1 overflow-auto"
style="background-color: #f8fafc; background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px); background-size: 24px 24px;"
>
<!-- Contenedor horizontal centrado -->
<div class="flex items-center justify-center min-h-full p-6">
<div class="flex items-center gap-8">
<!-- Páginas -->
<div
v-for="(page, pageIndex) in pages"
:key="page.id"
:data-page="pageIndex + 1"
class="relative group flex-shrink-0"
>
<!-- Header de página -->
<div class="flex flex-col items-center mb-3">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-600 dark:text-primary-dt/80">
<div class="flex-1 overflow-auto p-8" @click="$emit('click', $event)">
<div class="flex items-start justify-center gap-8 min-h-full">
<div v-for="(page, pageIndex) in pages" :key="page.id" class="relative group flex-shrink-0">
<div class="absolute -top-6 left-1/2 transform -translate-x-1/2 text-xs text-gray-500 whitespace-nowrap">
Página {{ pageIndex + 1 }}
</span>
</div>
<button
v-if="totalPages > 1"
@click="deletePage(pageIndex)"
:disabled="isExporting"
class="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 disabled:opacity-50 p-1 rounded hover:bg-red-50 transition-all"
class="absolute -top-6 right-0 w-6 h-6 bg-white rounded-full text-red-500 opacity-0 group-hover:opacity-100 flex items-center justify-center shadow-md hover:shadow-lg transition-all z-10"
title="Eliminar página"
>
<GoogleIcon name="delete" class="text-sm" />
</button>
</div>
<span class="text-xs text-gray-400 dark:text-primary-dt/50">
{{ currentPageSize.label }}
</span>
</div>
<!-- Contenedor de página con sombra -->
<div class="relative">
<!-- Sombra de página -->
<div class="absolute top-2 left-2 w-full h-full bg-gray-400/30 rounded-lg"></div>
<!-- Página PDF -->
<div
class="pdf-page relative bg-white rounded-lg border border-gray-300 dark:border-primary/20 overflow-hidden"
class="pdf-page relative bg-white shadow-lg rounded-md border transition-all duration-200 overflow-hidden"
:class="{
'ring-2 ring-blue-500 ring-opacity-50 shadow-lg': currentPage === pageIndex + 1,
'shadow-md hover:shadow-lg': currentPage !== pageIndex + 1,
'opacity-50': isExporting
}"
:style="{
width: `${scaledPageWidth}px`,
height: `${scaledPageHeight}px`
'ring-2 ring-blue-500': currentPage === pageIndex + 1,
'hover:shadow-xl': currentPage !== pageIndex + 1
}"
:style="{ width: `${scaledPageWidth}px`, height: `${scaledPageHeight}px` }"
@drop="(e) => handleDrop(e, pageIndex)"
@dragover="handleDragOver"
@click="(e) => handleClick(e, pageIndex)"
@click="setCurrentPage(pageIndex + 1)"
>
<!-- Área de contenido con márgenes visuales -->
<div class="relative w-full h-full">
<!-- Guías de margen -->
<div
class="absolute border border-dashed border-blue-300/40 pointer-events-none"
:style="{
top: `${PAGE_MARGIN * ZOOM_LEVEL}px`,
left: `${PAGE_MARGIN * ZOOM_LEVEL}px`,
width: `${(PAGE_WIDTH - (PAGE_MARGIN * 2)) * ZOOM_LEVEL}px`,
height: `${(PAGE_HEIGHT - (PAGE_MARGIN * 2)) * ZOOM_LEVEL}px`
}"
></div>
<!-- Elementos de la página con transformación -->
<div
class="absolute inset-0"
:style="{
transform: `scale(${ZOOM_LEVEL})`,
transformOrigin: 'top left',
width: `${PAGE_WIDTH}px`,
height: `${PAGE_HEIGHT}px`
}"
:style="{ transform: `scale(${ZOOM_LEVEL})`, transformOrigin: 'top left' }"
>
<slot
name="elements"
:page="page"
:pageIndex="pageIndex"
:pageWidth="PAGE_WIDTH"
:pageHeight="PAGE_HEIGHT"
:zoomLevel="ZOOM_LEVEL"
/>
</div>
</div>
<!-- Indicador de página vacía -->
<div
v-if="page.elements.length === 0"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
:style="{ transform: `scale(${1/ZOOM_LEVEL})` }"
>
<div class="text-center text-gray-400 dark:text-primary-dt/50">
<GoogleIcon name="description" class="text-4xl mb-2" />
<p class="text-sm font-medium">Página {{ pageIndex + 1 }}</p>
<p class="text-xs">Arrastra elementos aquí</p>
<slot name="elements" :page="page" :dimensions="currentPageSize" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Overlay durante exportación -->
<div
v-if="isExporting"
class="absolute inset-0 bg-white/90 dark:bg-primary-d/90 flex items-center justify-center z-50 backdrop-blur-sm"
>
<div class="text-center bg-white dark:bg-primary-d rounded-lg p-6 shadow-lg border border-gray-200 dark:border-primary/20">
<GoogleIcon name="picture_as_pdf" class="text-5xl text-red-600 dark:text-red-400 animate-pulse mb-3" />
<p class="text-lg font-semibold text-gray-900 dark:text-primary-dt mb-1">Generando PDF...</p>
<p class="text-sm text-gray-500 dark:text-primary-dt/70">Procesando {{ totalPages }} página{{ totalPages !== 1 ? 's' : '' }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.pdf-page {
transition: all 0.3s ease;
position: relative;
}
.pdf-page:hover {
transform: translateY(-2px);
}
.pdf-page.ring-2 {
transform: translateY(-4px);
}
.overflow-auto {
scroll-behavior: smooth;
}
.overflow-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-auto::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.overflow-auto::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.overflow-auto::-webkit-scrollbar {
height: 8px;
width: 8px;
}
</style>

View File

@ -32,20 +32,6 @@ const pageSizes = [
height: 1587,
description: 'Doble de A4'
},
{
name: 'Letter',
label: 'Carta (216 x 279 mm)',
width: 816,
height: 1056,
description: 'Estándar US'
},
{
name: 'Legal',
label: 'Oficio (216 x 356 mm)',
width: 816,
height: 1344,
description: 'Legal US'
},
{
name: 'Tabloid',
label: 'Tabloide (279 x 432 mm)',
@ -65,6 +51,10 @@ const selectSize = (size) => {
emit('update:modelValue', size.name);
isOpen.value = false;
};
const closeDropdown = () => {
isOpen.value = false;
};
</script>
<template>
@ -72,13 +62,13 @@ const selectSize = (size) => {
<!-- Selector principal -->
<button
@click="isOpen = !isOpen"
class="flex items-center gap-2 px-3 py-2 text-sm bg-white dark:bg-primary-d border border-gray-200 dark:border-primary/20 rounded-md hover:bg-gray-50 dark:hover:bg-primary/10 transition-colors"
class="flex items-center gap-2 px-3 py-2 text-sm bg-white dark:bg-primary-d border border-gray-200 dark:border-primary/20 rounded-lg hover:bg-gray-50 dark:hover:bg-primary/10 transition-colors shadow-sm min-w-0"
>
<GoogleIcon name="aspect_ratio" class="text-gray-500 dark:text-primary-dt/70" />
<span class="text-gray-700 dark:text-primary-dt">{{ selectedSize.name }}</span>
<GoogleIcon name="aspect_ratio" class="text-gray-500 dark:text-primary-dt/70 flex-shrink-0 text-lg" />
<span class="text-gray-700 dark:text-primary-dt font-medium truncate">{{ selectedSize.name }}</span>
<GoogleIcon
name="expand_more"
class="text-gray-400 dark:text-primary-dt/50 transition-transform"
class="text-gray-400 dark:text-primary-dt/50 transition-transform flex-shrink-0 text-lg"
:class="{ 'rotate-180': isOpen }"
/>
</button>
@ -86,53 +76,57 @@ const selectSize = (size) => {
<!-- Dropdown -->
<div
v-if="isOpen"
@click.away="isOpen = false"
class="absolute top-full left-0 mt-1 w-72 bg-white dark:bg-primary-d border border-gray-200 dark:border-primary/20 rounded-lg shadow-lg z-50 py-2"
v-click-away="closeDropdown"
class="absolute top-full right-0 mt-2 w-64 bg-white dark:bg-primary-d border border-gray-200 dark:border-primary/20 rounded-lg shadow-xl z-50 py-2"
>
<div class="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-primary-dt/70 uppercase tracking-wider border-b border-gray-100 dark:border-primary/20">
Tamaños de página
</div>
<div class="max-h-64 overflow-y-auto">
<div class="max-h-60 overflow-y-auto">
<button
v-for="size in pageSizes"
:key="size.name"
@click="selectSize(size)"
class="w-full flex items-center gap-3 px-3 py-3 hover:bg-gray-50 dark:hover:bg-primary/10 transition-colors text-left"
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-primary/10 transition-colors text-left"
:class="{
'bg-blue-50 dark:bg-blue-900/20': selectedSize.name === size.name
'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-500': selectedSize.name === size.name
}"
>
<!-- Miniatura del tamaño de página -->
<div class="flex-shrink-0">
<div
class="w-8 h-10 border border-gray-300 dark:border-primary/30 rounded-sm bg-white dark:bg-primary-d flex items-center justify-center"
class="w-6 h-8 border border-gray-300 dark:border-primary/30 rounded bg-white dark:bg-primary-d flex items-center justify-center"
:class="{
'border-blue-500 dark:border-blue-400': selectedSize.name === size.name
'border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20': selectedSize.name === size.name
}"
>
<div
class="bg-gray-200 dark:bg-primary/20 rounded-sm"
class="bg-gray-300 dark:bg-primary/40 rounded-sm"
:style="{
width: `${Math.min(20, (size.width / size.height) * 32)}px`,
height: `${Math.min(32, (size.height / size.width) * 20)}px`
width: `${Math.min(16, (size.width / size.height) * 24)}px`,
height: `${Math.min(24, (size.height / size.width) * 16)}px`
}"
:class="{
'bg-blue-200 dark:bg-blue-800': selectedSize.name === size.name
'bg-blue-400 dark:bg-blue-600': selectedSize.name === size.name
}"
></div>
</div>
</div>
<!-- Información del tamaño -->
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-primary-dt">{{ size.label }}</div>
<div class="text-xs text-gray-500 dark:text-primary-dt/70">{{ size.description }}</div>
<div class="text-xs text-gray-400 dark:text-primary-dt/50 mt-1">
{{ size.width }} x {{ size.height }} px
<div class="font-medium text-gray-900 dark:text-primary-dt text-sm">{{ size.name }}</div>
<div class="text-xs text-gray-500 dark:text-primary-dt/70 truncate">
{{ size.description }}
</div>
</div>
<!-- Indicador de selección -->
<div v-if="selectedSize.name === size.name" class="flex-shrink-0">
<GoogleIcon name="check" class="text-blue-500 dark:text-blue-400" />
<div class="w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
<GoogleIcon name="check" class="text-white text-xs" />
</div>
</div>
</button>
</div>

View File

@ -0,0 +1,354 @@
<script setup>
import { useEditor, EditorContent } from "@tiptap/vue-3";
import { StarterKit } from "@tiptap/starter-kit";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
import { Underline } from "@tiptap/extension-underline";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { FontSize } from "tiptap-extension-font-size";
import { TextAlign } from "@tiptap/extension-text-align";
import { watch } from "vue";
import GoogleIcon from "@Shared/GoogleIcon.vue";
const props = defineProps({
modelValue: { type: String, default: "" },
editable: { type: Boolean, default: true },
});
const emit = defineEmits(["update:modelValue", "focus", "blur"]);
const editor = useEditor({
content: props.modelValue,
editable: props.editable,
extensions: [
StarterKit,
Underline,
TextStyle,
Color.configure({ types: ["textStyle"] }),
FontSize.configure({ types: ["textStyle"] }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Table.configure({
resizable: true,
HTMLAttributes: {
class: 'tiptap-table',
},
}),
TableRow,
TableHeader,
TableCell,
],
onUpdate: ({ editor }) => emit("update:modelValue", editor.getHTML()),
onFocus: ({ editor }) => emit("focus", editor),
onBlur: ({ editor }) => emit("blur", editor),
});
watch(
() => props.modelValue,
(newValue) => {
if (editor.value && editor.value.getHTML() !== newValue) {
editor.value.commands.setContent(newValue, false);
}
}
);
watch(
() => props.editable,
(isEditable) => {
editor.value?.setEditable(isEditable);
}
);
// Acciones de tabla
const insertTable = () => {
editor.value?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
};
const addRowBefore = () => {
editor.value?.chain().focus().addRowBefore().run();
};
const addRowAfter = () => {
editor.value?.chain().focus().addRowAfter().run();
};
const deleteRow = () => {
editor.value?.chain().focus().deleteRow().run();
};
const addColumnBefore = () => {
editor.value?.chain().focus().addColumnBefore().run();
};
const addColumnAfter = () => {
editor.value?.chain().focus().addColumnAfter().run();
};
const deleteColumn = () => {
editor.value?.chain().focus().deleteColumn().run();
};
const deleteTable = () => {
editor.value?.chain().focus().deleteTable().run();
};
const mergeCells = () => {
editor.value?.chain().focus().mergeCells().run();
};
const splitCell = () => {
editor.value?.chain().focus().splitCell().run();
};
const toggleHeaderRow = () => {
editor.value?.chain().focus().toggleHeaderRow().run();
};
const toggleHeaderColumn = () => {
editor.value?.chain().focus().toggleHeaderColumn().run();
};
defineExpose({ editor });
</script>
<template>
<div class="table-editor-wrapper">
<!-- Toolbar de tabla -->
<div v-if="editable && editor" class="table-toolbar">
<div class="toolbar-section">
<button
@click="insertTable"
class="toolbar-btn"
title="Insertar tabla"
>
<GoogleIcon name="table_chart" />
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-section">
<button
@click="addRowBefore"
class="toolbar-btn"
title="Insertar fila arriba"
>
<GoogleIcon name="table_rows" />
<span class="btn-label"></span>
</button>
<button
@click="addRowAfter"
class="toolbar-btn"
title="Insertar fila abajo"
>
<GoogleIcon name="table_rows" />
<span class="btn-label"></span>
</button>
<button
@click="deleteRow"
class="toolbar-btn toolbar-btn-danger"
title="Eliminar fila"
>
<GoogleIcon name="delete" />
<span class="btn-label">Fila</span>
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-section">
<button
@click="addColumnBefore"
class="toolbar-btn"
title="Insertar columna a la izquierda"
>
<GoogleIcon name="view_column" />
<span class="btn-label"></span>
</button>
<button
@click="addColumnAfter"
class="toolbar-btn"
title="Insertar columna a la derecha"
>
<GoogleIcon name="view_column" />
<span class="btn-label"></span>
</button>
<button
@click="deleteColumn"
class="toolbar-btn toolbar-btn-danger"
title="Eliminar columna"
>
<GoogleIcon name="delete" />
<span class="btn-label">Col</span>
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-section">
<button
@click="mergeCells"
class="toolbar-btn"
title="Combinar celdas"
>
<GoogleIcon name="call_merge" />
</button>
<button
@click="splitCell"
class="toolbar-btn"
title="Dividir celda"
>
<GoogleIcon name="call_split" />
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-section">
<button
@click="toggleHeaderRow"
class="toolbar-btn"
:class="{ 'is-active': editor.isActive('tableHeader') }"
title="Toggle fila de encabezado"
>
<GoogleIcon name="text_fields" />
<span class="btn-label">H</span>
</button>
<button
@click="deleteTable"
class="toolbar-btn toolbar-btn-danger"
title="Eliminar tabla"
>
<GoogleIcon name="delete_forever" />
</button>
</div>
</div>
<!-- Editor -->
<EditorContent :editor="editor" class="table-editor-content" />
</div>
</template>
<style scoped>
.table-editor-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
flex-wrap: wrap;
}
.toolbar-section {
display: flex;
align-items: center;
gap: 0.25rem;
}
.toolbar-divider {
width: 1px;
height: 1.5rem;
background: #d1d5db;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.5rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
font-size: 0.75rem;
color: #374151;
}
.toolbar-btn:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.toolbar-btn.is-active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.toolbar-btn-danger:hover {
background: #fee2e2;
border-color: #ef4444;
color: #dc2626;
}
.btn-label {
font-size: 0.625rem;
font-weight: 500;
}
.table-editor-content {
flex: 1;
overflow: auto;
padding: 0.5rem;
}
:deep(.ProseMirror) {
outline: none;
width: 100%;
height: 100%;
}
/* Estilos para tablas Tiptap */
:deep(.tiptap-table) {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0;
overflow: hidden;
}
:deep(.tiptap-table td),
:deep(.tiptap-table th) {
min-width: 1em;
border: 1px solid #d1d5db;
padding: 0.5rem;
vertical-align: top;
box-sizing: border-box;
position: relative;
}
:deep(.tiptap-table th) {
font-weight: bold;
text-align: left;
background-color: #f9fafb;
}
:deep(.tiptap-table .selectedCell) {
background-color: #dbeafe;
}
:deep(.tiptap-table .column-resize-handle) {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
background-color: #3b82f6;
pointer-events: none;
}
:deep(.ProseMirror p) {
margin: 0;
}
</style>

View File

@ -0,0 +1,512 @@
<script setup>
import { ref, onMounted } from "vue";
import { useEditor, EditorContent } from "@tiptap/vue-3";
import { StarterKit } from "@tiptap/starter-kit";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
import { Underline } from "@tiptap/extension-underline";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { FontSize } from "tiptap-extension-font-size";
import { TextAlign } from "@tiptap/extension-text-align";
import GoogleIcon from "@Shared/GoogleIcon.vue";
const props = defineProps({
modelValue: { type: String, required: true },
});
const emit = defineEmits(["save", "cancel"]);
const editor = useEditor({
content: props.modelValue,
editable: true,
extensions: [
StarterKit,
Underline,
TextStyle,
Color.configure({ types: ["textStyle"] }),
FontSize.configure({ types: ["textStyle"] }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Table.configure({
resizable: true,
HTMLAttributes: {
class: 'tiptap-table',
},
}),
TableRow,
TableHeader,
TableCell,
],
});
onMounted(() => {
// Focus en el editor al abrir
setTimeout(() => {
editor.value?.commands.focus();
}, 100);
});
const handleSave = () => {
emit("save", editor.value?.getHTML());
};
const handleCancel = () => {
emit("cancel");
};
// Acciones de tabla
const insertTable = () => {
editor.value?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
};
const addRowBefore = () => {
editor.value?.chain().focus().addRowBefore().run();
};
const addRowAfter = () => {
editor.value?.chain().focus().addRowAfter().run();
};
const deleteRow = () => {
editor.value?.chain().focus().deleteRow().run();
};
const addColumnBefore = () => {
editor.value?.chain().focus().addColumnBefore().run();
};
const addColumnAfter = () => {
editor.value?.chain().focus().addColumnAfter().run();
};
const deleteColumn = () => {
editor.value?.chain().focus().deleteColumn().run();
};
const deleteTable = () => {
if (confirm("¿Estás seguro de eliminar toda la tabla?")) {
editor.value?.chain().focus().deleteTable().run();
}
};
const mergeCells = () => {
editor.value?.chain().focus().mergeCells().run();
};
const splitCell = () => {
editor.value?.chain().focus().splitCell().run();
};
const toggleHeaderRow = () => {
editor.value?.chain().focus().toggleHeaderRow().run();
};
const toggleHeaderColumn = () => {
editor.value?.chain().focus().toggleHeaderColumn().run();
};
// Formato de texto
const toggleBold = () => {
editor.value?.chain().focus().toggleBold().run();
};
const toggleItalic = () => {
editor.value?.chain().focus().toggleItalic().run();
};
const toggleUnderline = () => {
editor.value?.chain().focus().toggleUnderline().run();
};
const setTextColor = (color) => {
editor.value?.chain().focus().setColor(color).run();
};
const fontSize = ref('12');
const FONT_SIZES = ['10', '12', '14', '16', '18', '20', '24'];
const changeFontSize = (size) => {
editor.value?.chain().focus().setFontSize(`${size}px`).run();
};
</script>
<template>
<div class="modal-overlay" @click.self="handleCancel">
<div class="modal-container">
<!-- Header -->
<div class="modal-header">
<div class="flex items-center gap-2">
<GoogleIcon name="table_chart" class="text-blue-600 text-2xl" />
<h2 class="text-xl font-semibold text-gray-900">Editor de Tabla</h2>
</div>
<button
@click="handleCancel"
class="close-btn"
title="Cerrar (ESC)"
>
<GoogleIcon name="close" />
</button>
</div>
<!-- Toolbar -->
<div class="toolbar">
<!-- Acciones de tabla -->
<div class="toolbar-section">
<span class="section-label">Tabla:</span>
<button @click="insertTable" class="toolbar-btn" title="Insertar tabla">
<GoogleIcon name="add" />
<span>Nueva</span>
</button>
<button @click="deleteTable" class="toolbar-btn danger" title="Eliminar tabla">
<GoogleIcon name="delete_forever" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- Filas -->
<div class="toolbar-section">
<span class="section-label">Filas:</span>
<button @click="addRowBefore" class="toolbar-btn" title="Insertar fila arriba">
<GoogleIcon name="arrow_upward" />
</button>
<button @click="addRowAfter" class="toolbar-btn" title="Insertar fila abajo">
<GoogleIcon name="arrow_downward" />
</button>
<button @click="deleteRow" class="toolbar-btn danger" title="Eliminar fila">
<GoogleIcon name="remove" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- Columnas -->
<div class="toolbar-section">
<span class="section-label">Columnas:</span>
<button @click="addColumnBefore" class="toolbar-btn" title="Insertar columna izquierda">
<GoogleIcon name="arrow_back" />
</button>
<button @click="addColumnAfter" class="toolbar-btn" title="Insertar columna derecha">
<GoogleIcon name="arrow_forward" />
</button>
<button @click="deleteColumn" class="toolbar-btn danger" title="Eliminar columna">
<GoogleIcon name="remove" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- Celdas -->
<div class="toolbar-section">
<span class="section-label">Celdas:</span>
<button @click="mergeCells" class="toolbar-btn" title="Combinar celdas">
<GoogleIcon name="call_merge" />
</button>
<button @click="splitCell" class="toolbar-btn" title="Dividir celda">
<GoogleIcon name="call_split" />
</button>
<button @click="toggleHeaderRow" class="toolbar-btn" title="Toggle header fila">
<GoogleIcon name="title" />
</button>
</div>
<div class="toolbar-divider"></div>
<!-- Formato de texto -->
<div class="toolbar-section">
<span class="section-label">Formato:</span>
<button
@click="toggleBold"
class="toolbar-btn"
:class="{ active: editor?.isActive('bold') }"
title="Negrita"
>
<GoogleIcon name="format_bold" />
</button>
<button
@click="toggleItalic"
class="toolbar-btn"
:class="{ active: editor?.isActive('italic') }"
title="Cursiva"
>
<GoogleIcon name="format_italic" />
</button>
<button
@click="toggleUnderline"
class="toolbar-btn"
:class="{ active: editor?.isActive('underline') }"
title="Subrayado"
>
<GoogleIcon name="format_underlined" />
</button>
<select
v-model="fontSize"
@change="changeFontSize(fontSize)"
class="font-size-select"
>
<option v-for="size in FONT_SIZES" :key="size" :value="size">
{{ size }}px
</option>
</select>
</div>
</div>
<!-- Editor Content -->
<div class="editor-wrapper">
<EditorContent :editor="editor" class="editor-content" />
</div>
<!-- Footer -->
<div class="modal-footer">
<div class="text-sm text-gray-500">
<GoogleIcon name="info" class="inline text-base" />
Usa <kbd>Tab</kbd> para navegar entre celdas
</div>
<div class="flex gap-2">
<button @click="handleCancel" class="btn btn-secondary">
Cancelar
</button>
<button @click="handleSave" class="btn btn-primary">
<GoogleIcon name="check" />
Guardar
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 1rem;
}
.modal-container {
background: white;
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 90vw;
max-height: 90vh;
width: 1200px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.close-btn {
padding: 0.5rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s;
}
.close-btn:hover {
background: #f3f4f6;
color: #111827;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
overflow-x: auto;
flex-wrap: wrap;
}
.toolbar-section {
display: flex;
align-items: center;
gap: 0.375rem;
}
.section-label {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
margin-right: 0.25rem;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #374151;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.toolbar-btn:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.toolbar-btn.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.toolbar-btn.danger:hover {
background: #fee2e2;
border-color: #ef4444;
color: #dc2626;
}
.toolbar-divider {
width: 1px;
height: 1.5rem;
background: #d1d5db;
}
.font-size-select {
padding: 0.375rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
background: white;
cursor: pointer;
}
.editor-wrapper {
flex: 1;
overflow: auto;
padding: 1.5rem;
background: #fafafa;
}
.editor-content {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1.5rem;
min-height: 400px;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
kbd {
padding: 0.125rem 0.375rem;
background: #e5e7eb;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: monospace;
}
.btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover {
background: #f3f4f6;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
/* Estilos del editor Tiptap */
:deep(.ProseMirror) {
outline: none;
min-height: 400px;
}
:deep(.tiptap-table) {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0;
overflow: hidden;
}
:deep(.tiptap-table td),
:deep(.tiptap-table th) {
min-width: 1em;
border: 2px solid #d1d5db;
padding: 0.75rem;
vertical-align: top;
box-sizing: border-box;
position: relative;
}
:deep(.tiptap-table th) {
font-weight: bold;
text-align: left;
background-color: #f3f4f6;
}
:deep(.tiptap-table .selectedCell) {
background-color: #dbeafe;
}
:deep(.tiptap-table .column-resize-handle) {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
background-color: #3b82f6;
pointer-events: none;
}
:deep(.ProseMirror p) {
margin: 0;
}
</style>

View File

@ -1,233 +1,185 @@
<script setup>
import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import { computed, ref } from "vue";
import GoogleIcon from "@Shared/GoogleIcon.vue";
/** Propiedades */
const props = defineProps({
element: {
type: Object,
default: null
},
visible: {
type: Boolean,
default: false
}
editor: { type: Object, default: null },
selectedElement: { type: Object, default: null },
isTableEditing: { type: Boolean, default: false },
});
/** Eventos */
const emit = defineEmits(['update']);
const colorGrid = ref(null);
/** Propiedades computadas */
const formatting = computed(() => props.element?.formatting || {});
const hasTextElement = computed(() => props.element?.type === 'text');
// CORRECCIÓN: Usar props.editor directamente para mantener la reactividad.
const isTextSelected = computed(
() => props.selectedElement?.type === "text" && props.editor
);
/** Métodos */
const toggleBold = () => {
if (!hasTextElement.value) return;
updateFormatting('bold', !formatting.value.bold);
};
const toggleItalic = () => {
if (!hasTextElement.value) return;
updateFormatting('italic', !formatting.value.italic);
};
const toggleUnderline = () => {
if (!hasTextElement.value) return;
updateFormatting('underline', !formatting.value.underline);
};
const updateFontSize = (size) => {
if (!hasTextElement.value) return;
updateFormatting('fontSize', size);
};
const updateTextAlign = (align) => {
if (!hasTextElement.value) return;
updateFormatting('textAlign', align);
};
const updateColor = (color) => {
if (!hasTextElement.value) return;
updateFormatting('color', color);
};
const updateFormatting = (key, value) => {
const newFormatting = { ...formatting.value, [key]: value };
emit('update', {
id: props.element.id,
formatting: newFormatting
});
};
/** Colores predefinidos */
const predefinedColors = [
'#000000', '#333333', '#666666', '#999999',
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
'#FF00FF', '#00FFFF', '#FFA500', '#800080'
const FONT_SIZES = ["12", "14", "16", "20", "24", "30"];
const PRIMARY_COLORS = [
"#000000",
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FFFFFF",
"#800000",
"#008000",
"#000080",
"#808000",
"#800080",
"#008080",
"#C0C0C0",
"#808080",
];
/** Tamaños de fuente */
const fontSizes = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72];
const currentFontSize = computed({
get() {
if (!props.editor) return "16";
const sz = props.editor.getAttributes("textStyle").fontSize;
return sz ? sz.replace("px", "") : "16";
},
set(value) {
if (props.editor) {
props.editor.chain().focus().setFontSize(`${value}px`).run();
}
},
});
const setColor = (color) => {
if (props.editor) {
props.editor.chain().focus().setColor(color).run();
if (colorGrid.value) {
colorGrid.value.classList.add("hidden");
}
}
};
const toggleColorGrid = () => {
if (colorGrid.value) {
colorGrid.value.classList.toggle("hidden");
}
};
const currentColour = computed(() => {
if (!props.editor) return "#000000";
return props.editor.getAttributes("textStyle").color || "#000000";
});
const textActions = [
{
action: () => {
if (props.editor) props.editor.chain().focus().toggleBold().run();
},
icon: "format_bold",
isActive: "bold",
},
{
action: () => {
if (props.editor) props.editor.chain().focus().toggleItalic().run();
},
icon: "format_italic",
isActive: "italic",
},
{
action: () => {
if (props.editor) props.editor.chain().focus().toggleUnderline().run();
},
icon: "format_underlined",
isActive: "underline",
},
{ type: "divider" },
{
action: () => {
if (props.editor) props.editor.chain().focus().setTextAlign("left").run();
},
icon: "format_align_left",
isActive: { textAlign: "left" },
},
{
action: () => {
if (props.editor)
props.editor.chain().focus().setTextAlign("center").run();
},
icon: "format_align_center",
isActive: { textAlign: "center" },
},
{
action: () => {
if (props.editor)
props.editor.chain().focus().setTextAlign("right").run();
},
icon: "format_align_right",
isActive: { textAlign: "right" },
},
];
</script>
<template>
<div
v-if="visible && hasTextElement"
class="flex items-center gap-6 px-4 py-2 bg-gray-50 dark:bg-primary-d/50 border-b border-gray-200 dark:border-primary/20"
class="w-full bg-white border-b px-4 py-2 shadow-sm h-14 flex items-center gap-2"
>
<!-- Estilo de texto -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-primary-dt/80">Estilo:</span>
<div class="flex gap-1">
<template v-if="isTextSelected">
<!-- Botones de formato de texto -->
<template v-if="editor">
<button
@click="toggleBold"
:class="[
'w-8 h-8 rounded flex items-center justify-center text-sm font-bold transition-colors',
formatting.bold
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Negrita (Ctrl+B)"
v-for="item in textActions.filter((i) => !i.type)"
:key="item.icon"
@click="item.action"
@mousedown.prevent
:class="{ 'bg-gray-200': editor.isActive(item.isActive) }"
class="p-2 rounded hover:bg-gray-100"
>
B
<GoogleIcon :name="item.icon" />
</button>
<button
@click="toggleItalic"
:class="[
'w-8 h-8 rounded flex items-center justify-center text-sm italic transition-colors',
formatting.italic
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Cursiva (Ctrl+I)"
>
I
</button>
<div class="w-px h-6 bg-gray-200 mx-1"></div>
<button
@click="toggleUnderline"
:class="[
'w-8 h-8 rounded flex items-center justify-center text-sm underline transition-colors',
formatting.underline
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Subrayado (Ctrl+U)"
>
U
</button>
</div>
</div>
<!-- Separador -->
<div class="w-px h-6 bg-gray-300 dark:bg-primary/30"></div>
<!-- Tamaño de fuente -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-primary-dt/80">Tamaño:</span>
<!-- Controles de tamaño y color para texto -->
<div class="flex items-center gap-2" @mousedown.stop>
<select
:value="formatting.fontSize || 12"
@change="updateFontSize(parseInt($event.target.value))"
class="px-2 py-1 text-sm border border-gray-200 rounded bg-white dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
v-model="currentFontSize"
class="px-2 py-1 text-sm border border-gray-300 rounded"
>
<option v-for="size in fontSizes" :key="size" :value="size">
<option v-for="size in FONT_SIZES" :key="size" :value="size">
{{ size }}px
</option>
</select>
</div>
<!-- Separador -->
<div class="w-px h-6 bg-gray-300 dark:bg-primary/30"></div>
<!-- Alineación -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-primary-dt/80">Alinear:</span>
<div class="flex gap-1">
<!-- Selector de colores -->
<div class="relative">
<button
@click="updateTextAlign('left')"
:class="[
'w-8 h-8 rounded flex items-center justify-center transition-colors',
(formatting.textAlign || 'left') === 'left'
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Alinear izquierda"
type="button"
@click="toggleColorGrid"
class="w-8 h-6 border border-gray-300 rounded flex items-center justify-center"
:style="{ backgroundColor: currentColour }"
>
<GoogleIcon name="format_align_left" class="text-sm" />
<GoogleIcon name="palette" class="text-xs text-white mix-blend-difference" />
</button>
<button
@click="updateTextAlign('center')"
:class="[
'w-8 h-8 rounded flex items-center justify-center transition-colors',
formatting.textAlign === 'center'
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Centrar"
>
<GoogleIcon name="format_align_center" class="text-sm" />
</button>
<button
@click="updateTextAlign('right')"
:class="[
'w-8 h-8 rounded flex items-center justify-center transition-colors',
formatting.textAlign === 'right'
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-700 hover:bg-blue-50 border border-gray-200 dark:bg-primary-d dark:text-primary-dt dark:hover:bg-primary/10 dark:border-primary/20'
]"
title="Alinear derecha"
>
<GoogleIcon name="format_align_right" class="text-sm" />
</button>
</div>
</div>
<!-- Separador -->
<div class="w-px h-6 bg-gray-300 dark:bg-primary/30"></div>
<!-- Color de texto -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-primary-dt/80">Color:</span>
<div class="flex items-center gap-2">
<!-- Color actual -->
<div
class="w-8 h-8 rounded border-2 border-gray-300 cursor-pointer relative overflow-hidden"
:style="{ backgroundColor: formatting.color || '#000000' }"
title="Color actual"
ref="colorGrid"
class="absolute top-full left-0 mt-1 p-2 bg-white border rounded shadow-lg z-50 hidden"
>
<input
type="color"
:value="formatting.color || '#000000'"
@input="updateColor($event.target.value)"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
<div class="grid grid-cols-4 gap-1">
<button
v-for="color in PRIMARY_COLORS"
:key="color"
type="button"
@click="setColor(color)"
class="w-6 h-6 border rounded cursor-pointer hover:scale-110"
:style="{ backgroundColor: color }"
/>
</div>
</div>
</div>
</div>
</template>
</template>
<!-- Colores rápidos -->
<div class="flex gap-1">
<button
v-for="color in predefinedColors.slice(0, 6)"
:key="color"
@click="updateColor(color)"
class="w-6 h-6 rounded border border-gray-300 hover:scale-110 transition-transform"
:class="{
'ring-2 ring-blue-500': (formatting.color || '#000000') === color
}"
:style="{ backgroundColor: color }"
:title="color"
></button>
</div>
</div>
</div>
<!-- Información del elemento -->
<div class="ml-auto flex items-center gap-2 text-xs text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="text_fields" class="text-sm" />
<span>Elemento de texto seleccionado</span>
<div v-else class="text-sm text-gray-400">
Selecciona un elemento para ver sus opciones
</div>
</div>
</template>

View File

@ -0,0 +1,128 @@
<script setup>
import { useEditor, EditorContent } from "@tiptap/vue-3";
import { StarterKit } from "@tiptap/starter-kit";
import { Underline } from "@tiptap/extension-underline";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { FontSize } from "tiptap-extension-font-size";
import { TextAlign } from "@tiptap/extension-text-align";
import { watch } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
editable: { type: Boolean, default: true },
});
const emit = defineEmits(["update:modelValue", "focus", "blur"]);
const editor = useEditor({
content: props.modelValue,
editable: props.editable,
extensions: [
StarterKit,
Underline,
TextStyle,
Color.configure({ types: ["textStyle"] }),
FontSize.configure({ types: ["textStyle"] }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
],
onUpdate: ({ editor }) => emit("update:modelValue", editor.getHTML()),
onFocus: ({ editor }) => emit("focus", editor),
onBlur: ({ editor }) => emit("blur", editor),
});
// Observa cambios en el contenido desde fuera
watch(
() => props.modelValue,
(newValue) => {
if (editor.value && editor.value.getHTML() !== newValue) {
editor.value.commands.setContent(newValue, false);
}
}
);
// Observa cambios en el estado 'editable' desde fuera
watch(
() => props.editable,
(isEditable) => {
editor.value?.setEditable(isEditable);
}
);
defineExpose({ editor });
</script>
<template>
<div class="tiptap-wrapper">
<EditorContent :editor="editor" />
</div>
</template>
<style scoped>
.tiptap-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.ProseMirror) {
padding: 0.5rem;
outline: none;
width: 100%;
height: 100%;
word-wrap: break-word;
word-break: break-word;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
line-height: 1.4;
}
/* Prevenir que el texto se desborde horizontalmente */
:deep(.ProseMirror p) {
margin: 0 0 0.25em 0;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
max-width: 100%;
}
/* Asegurar que spans respeten el ancho */
:deep(.ProseMirror span) {
display: inline;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
}
/* Estilos para cuando NO es editable */
:deep(.ProseMirror[contenteditable="false"]) {
cursor: default;
overflow: hidden;
}
/* Estilos para cuando SÍ es editable */
:deep(.ProseMirror[contenteditable="true"]:focus) {
outline: none;
box-shadow: none;
}
/* Controlar listas */
:deep(.ProseMirror ul),
:deep(.ProseMirror ol) {
padding-left: 1.5rem;
margin: 0 0 0.5em 0;
}
/* Alineación de texto */
:deep(p[style*="text-align: center"]) {
text-align: center;
}
:deep(p[style*="text-align: right"]) {
text-align: right;
}
:deep(p[style*="text-align: left"]) {
text-align: left;
}
</style>

View File

@ -1,261 +0,0 @@
<script setup>
import { ref, computed, nextTick } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
pages: {
type: Array,
default: () => [{ id: 1, elements: [] }]
},
selectedElementId: String,
isExporting: Boolean
});
/** Eventos */
const emit = defineEmits(['drop', 'dragover', 'click', 'add-page', 'delete-page', 'page-change']);
/** Referencias */
const viewportRef = ref(null);
const currentPage = ref(1);
/** Constantes de diseño */
const PAGE_WIDTH = 794; // A4 width in pixels (210mm @ 96dpi * 3.78)
const PAGE_HEIGHT = 1123; // A4 height in pixels (297mm @ 96dpi * 3.78)
const PAGE_MARGIN = 40;
const ZOOM_LEVEL = 0.8; // Factor de escala para visualización
/** Propiedades computadas */
const scaledPageWidth = computed(() => PAGE_WIDTH * ZOOM_LEVEL);
const scaledPageHeight = computed(() => PAGE_HEIGHT * ZOOM_LEVEL);
const totalPages = computed(() => props.pages.length);
/** Métodos */
const handleDrop = (event, pageIndex) => {
event.preventDefault();
const pageElement = event.currentTarget;
const rect = pageElement.getBoundingClientRect();
// Calcular posición relativa a la página específica
const relativeX = (event.clientX - rect.left) / ZOOM_LEVEL;
const relativeY = (event.clientY - rect.top) / ZOOM_LEVEL;
emit('drop', {
originalEvent: event,
pageIndex,
x: Math.max(PAGE_MARGIN, Math.min(PAGE_WIDTH - PAGE_MARGIN, relativeX)),
y: Math.max(PAGE_MARGIN, Math.min(PAGE_HEIGHT - PAGE_MARGIN, relativeY))
});
};
const handleDragOver = (event) => {
event.preventDefault();
emit('dragover', event);
};
const handleClick = (event, pageIndex) => {
if (event.target.classList.contains('pdf-page')) {
emit('click', { originalEvent: event, pageIndex });
}
};
const addPage = () => {
emit('add-page');
nextTick(() => {
scrollToPage(totalPages.value);
});
};
const deletePage = (pageIndex) => {
if (totalPages.value > 1) {
emit('delete-page', pageIndex);
}
};
const scrollToPage = (pageNumber) => {
if (viewportRef.value) {
const pageElement = viewportRef.value.querySelector(`[data-page="${pageNumber}"]`);
if (pageElement) {
pageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
};
const setCurrentPage = (pageNumber) => {
currentPage.value = pageNumber;
emit('page-change', pageNumber);
};
/** Métodos expuestos */
defineExpose({
scrollToPage,
setCurrentPage,
PAGE_WIDTH,
PAGE_HEIGHT,
ZOOM_LEVEL
});
</script>
<template>
<div class="flex-1 flex flex-col bg-gray-100 dark:bg-primary-d/20">
<!-- Toolbar de páginas -->
<div class="flex items-center justify-between px-4 py-2 bg-white dark:bg-primary-d border-b border-gray-200 dark:border-primary/20">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600 dark:text-primary-dt">
Página {{ currentPage }} de {{ totalPages }}
</span>
<div class="flex items-center gap-1">
<button
@click="setCurrentPage(Math.max(1, currentPage - 1))"
:disabled="currentPage <= 1"
class="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt"
>
<GoogleIcon name="keyboard_arrow_left" class="text-lg" />
</button>
<button
@click="setCurrentPage(Math.min(totalPages, currentPage + 1))"
:disabled="currentPage >= totalPages"
class="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt"
>
<GoogleIcon name="keyboard_arrow_right" class="text-lg" />
</button>
</div>
</div>
<div class="flex items-center gap-2">
<button
@click="addPage"
:disabled="isExporting"
class="flex items-center gap-1 px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50"
>
<GoogleIcon name="add" class="text-sm" />
Nueva Página
</button>
</div>
</div>
<!-- Viewport de páginas -->
<div
ref="viewportRef"
class="flex-1 overflow-auto p-8"
style="background: linear-gradient(45deg, #f0f0f0 25%, transparent 25%), linear-gradient(-45deg, #f0f0f0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f0f0f0 75%), linear-gradient(-45deg, transparent 75%, #f0f0f0 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px;"
>
<div class="flex flex-col items-center gap-8">
<!-- Páginas -->
<div
v-for="(page, pageIndex) in pages"
:key="page.id"
:data-page="pageIndex + 1"
class="relative"
@mouseenter="setCurrentPage(pageIndex + 1)"
>
<!-- Número de página -->
<div class="absolute -top-6 left-0 flex items-center gap-2 text-xs text-gray-500 dark:text-primary-dt/70">
<span>Página {{ pageIndex + 1 }}</span>
<button
v-if="totalPages > 1"
@click="deletePage(pageIndex)"
:disabled="isExporting"
class="text-red-500 hover:text-red-700 disabled:opacity-50"
title="Eliminar página"
>
<GoogleIcon name="delete" class="text-sm" />
</button>
</div>
<!-- Página PDF -->
<div
class="pdf-page relative bg-white rounded-lg shadow-lg border border-gray-300 dark:border-primary/20"
:class="{
'ring-2 ring-blue-500': currentPage === pageIndex + 1,
'opacity-50': isExporting
}"
:style="{
width: `${scaledPageWidth}px`,
height: `${scaledPageHeight}px`,
transform: `scale(${ZOOM_LEVEL})`,
transformOrigin: 'top left'
}"
@drop="(e) => handleDrop(e, pageIndex)"
@dragover="handleDragOver"
@click="(e) => handleClick(e, pageIndex)"
>
<!-- Márgenes visuales -->
<div
class="absolute border border-dashed border-gray-300 dark:border-primary/30 pointer-events-none"
:style="{
top: `${PAGE_MARGIN}px`,
left: `${PAGE_MARGIN}px`,
width: `${PAGE_WIDTH - (PAGE_MARGIN * 2)}px`,
height: `${PAGE_HEIGHT - (PAGE_MARGIN * 2)}px`
}"
></div>
<!-- Elementos de la página -->
<slot
name="elements"
:page="page"
:pageIndex="pageIndex"
:pageWidth="PAGE_WIDTH"
:pageHeight="PAGE_HEIGHT"
/>
<!-- Indicador de página vacía -->
<div
v-if="page.elements.length === 0"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="text-center text-gray-400 dark:text-primary-dt/50">
<GoogleIcon name="description" class="text-4xl mb-2" />
<p class="text-sm">Página {{ pageIndex + 1 }}</p>
<p class="text-xs">Arrastra elementos aquí</p>
</div>
</div>
</div>
<!-- Regla inferior (tamaño de referencia) -->
<div class="mt-2 text-xs text-gray-400 dark:text-primary-dt/50 text-center">
210 × 297 mm (A4)
</div>
</div>
<!-- Botón para agregar página al final -->
<button
@click="addPage"
:disabled="isExporting"
class="flex flex-col items-center justify-center w-40 h-20 border-2 border-dashed border-gray-300 dark:border-primary/30 rounded-lg hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors disabled:opacity-50"
>
<GoogleIcon name="add" class="text-2xl text-gray-400 dark:text-primary-dt/50" />
<span class="text-xs text-gray-500 dark:text-primary-dt/70 mt-1">Nueva Página</span>
</button>
</div>
</div>
<!-- Overlay durante exportación -->
<div
v-if="isExporting"
class="absolute inset-0 bg-white/80 dark:bg-primary-d/80 flex items-center justify-center z-50"
>
<div class="text-center">
<GoogleIcon name="picture_as_pdf" class="text-4xl text-red-600 dark:text-red-400 animate-pulse mb-2" />
<p class="text-sm font-medium text-gray-900 dark:text-primary-dt">Generando PDF...</p>
<p class="text-xs text-gray-500 dark:text-primary-dt/70">Procesando {{ totalPages }} página{{ totalPages !== 1 ? 's' : '' }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.pdf-page {
transition: all 0.2s ease;
}
.pdf-page:hover {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
</style>

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

@ -0,0 +1,464 @@
import { ref, computed, watch } from 'vue';
import { useForm, useApi } from '@/services/Api';
import { documentService } from '@/services/documentService';
/**
* Composable para manejar documentos
*
*/
export function useDocument(config = {}) {
// ============================================
// ESTADO
// ============================================
const documentId = ref(config.documentId || null);
const documentType = ref(config.documentType || 'COTIZACION');
const isLoading = ref(false);
const isSaving = ref(false);
const error = ref(null);
// Documento completo
const document = ref(null);
// Formulario de datos
const formData = ref({});
const branding = ref({
documentType: documentType.value,
primaryColor: '#2c50dd',
slogan: '',
logoUrl: null,
logoPreview: null
});
const productos = ref([]);
const totales = ref({
// Totales principales
subtotal: 0,
iva: 0,
total: 0,
// Totales adicionales
subtotal1: 0,
descuentoTotal: 0,
subtotal2: 0,
impuestosTrasladados: 0
});
// ============================================
// COMPUTED
// ============================================
/**
* Transforma datos
*/
const documentPayload = computed(() => {
return {
tipo: documentType.value,
estado: 'BORRADOR',
templateConfig: {
primaryColor: branding.value.primaryColor,
logoUrl: branding.value.logoUrl,
slogan: branding.value.slogan
},
datos: {
// Datos de la empresa
empresa: {
nombre: formData.value.empresaNombre || '',
rfc: formData.value.empresaRFC || '',
email: formData.value.empresaEmail || '',
telefono: formData.value.empresaTelefono || '',
direccion: formData.value.empresaDireccion || '',
web: formData.value.empresaWeb || '',
// Campos específicos para facturas (CFDI)
lugar: formData.value.empresaLugar || '',
cfdi: formData.value.empresaCfdi || '',
regimen: formData.value.empresaRegimen || ''
},
// Datos bancarios (para cotizaciones)
bancos: {
banco: formData.value.bancoBanco || '',
tipoCuenta: formData.value.bancoTipoCuenta || '',
cuenta: formData.value.bancoCuenta || ''
},
// Datos del cliente
cliente: {
nombre: formData.value.clienteNombre || '',
rfc: formData.value.clienteRFC || '',
domicilio: formData.value.clienteDomicilio || '',
telefono: formData.value.clienteTelefono || '',
// Campo específico para facturas
regimen: formData.value.clienteRegimen || ''
},
// Datos del ejecutivo (para cotizaciones)
ejecutivo: {
nombre: formData.value.ejecutivoNombre || '',
correo: formData.value.ejecutivoCorreo || '',
celular: formData.value.ejecutivoCelular || ''
},
// Detalles del documento
documento: {
// Común para todos
folio: formData.value.folio || '',
observaciones: formData.value.observaciones || '',
// Específico para cotizaciones
fechaRealizacion: formData.value.fechaRealizacion || '',
vigencia: formData.value.vigencia || '',
// Específico para facturas
serie: formData.value.serie || '',
fechaEmision: formData.value.fechaEmision || '',
tipoComprobante: formData.value.tipoComprobante || '',
// Específico para remisiones
fechaRemision: formData.value.fechaRemision || ''
},
// Productos y totales
productos: productos.value,
totales: totales.value
}
};
});
// ============================================
// MÉTODOS
// ============================================
/**
* Cargar documento existente
*/
const loadDocument = async (id) => {
isLoading.value = true;
error.value = null;
try {
const config = documentService.getById(id);
const api = useApi();
await api.load({
...config,
options: {
onSuccess: (data) => {
document.value = data;
hydrateForm(data);
},
onFail: (err) => {
error.value = err.message || 'Error al cargar documento';
}
}
});
} catch (err) {
error.value = err.message;
} finally {
isLoading.value = false;
}
};
/**
* Hidratar formulario con datos del servidor
*
* Llena todos los campos del formulario con los datos de un documento cargado
*
*/
const hydrateForm = (documentData) => {
documentType.value = documentData.tipo;
// Actualizar branding/template
branding.value = {
primaryColor: documentData.templateConfig.primaryColor || '#2c50dd',
slogan: documentData.templateConfig.slogan || '',
logoUrl: documentData.templateConfig.logoUrl || null,
logoPreview: documentData.templateConfig.logoUrl || null
};
// Llenar todos los campos del formulario
formData.value = {
// Datos de la empresa
empresaNombre: documentData.datos.empresa.nombre || '',
empresaRFC: documentData.datos.empresa.rfc || '',
empresaEmail: documentData.datos.empresa.email || '',
empresaTelefono: documentData.datos.empresa.telefono || '',
empresaDireccion: documentData.datos.empresa.direccion || '',
empresaWeb: documentData.datos.empresa.web || '',
// Campos específicos de facturas
empresaLugar: documentData.datos.empresa.lugar || '',
empresaCfdi: documentData.datos.empresa.cfdi || '',
empresaRegimen: documentData.datos.empresa.regimen || '',
// Datos bancarios (puede no existir en facturas)
bancoBanco: documentData.datos.bancos?.banco || '',
bancoTipoCuenta: documentData.datos.bancos?.tipoCuenta || '',
bancoCuenta: documentData.datos.bancos?.cuenta || '',
// Datos del cliente
clienteNombre: documentData.datos.cliente.nombre || '',
clienteRFC: documentData.datos.cliente.rfc || '',
clienteDomicilio: documentData.datos.cliente.domicilio || '',
clienteTelefono: documentData.datos.cliente.telefono || '',
clienteRegimen: documentData.datos.cliente.regimen || '',
// Datos del ejecutivo (puede no existir en facturas)
ejecutivoNombre: documentData.datos.ejecutivo?.nombre || '',
ejecutivoCorreo: documentData.datos.ejecutivo?.correo || '',
ejecutivoCelular: documentData.datos.ejecutivo?.celular || '',
// Detalles del documento
folio: documentData.datos.documento?.folio || '',
observaciones: documentData.datos.documento?.observaciones || '',
// Específico para cotizaciones
fechaRealizacion: documentData.datos.documento?.fechaRealizacion || '',
vigencia: documentData.datos.documento?.vigencia || '',
// Específico para facturas
serie: documentData.datos.documento?.serie || '',
fechaEmision: documentData.datos.documento?.fechaEmision || '',
tipoComprobante: documentData.datos.documento?.tipoComprobante || '',
// Específico para remisiones
fechaRemision: documentData.datos.documento?.fechaRemision || '',
};
// Productos y totales
productos.value = documentData.datos.productos || [];
totales.value = documentData.datos.totales || {
subtotal: 0,
iva: 0,
total: 0
};
};
/**
* Inicializar formulario vacío desde configuración de template
*
*/
const initializeForm = (templateConfig) => {
const data = {};
templateConfig.campos.forEach((seccion) => {
seccion.campos.forEach((campo) => {
data[campo.key] = campo.defaultValue || '';
});
});
formData.value = data;
};
/**
* Guardar documento (crear o actualizar)
*/
const saveDocument = async (options = {}) => {
isSaving.value = true;
error.value = null;
try {
const config = documentService.save(
documentPayload.value,
documentId.value
);
const form = useForm(documentPayload.value);
await form.load({
...config,
options: {
onSuccess: (data) => {
document.value = data;
documentId.value = data.id;
if (options.onSuccess) {
options.onSuccess(data);
}
},
onFail: (err) => {
error.value = err.message || 'Error al guardar documento';
if (options.onFail) {
options.onFail(err);
}
}
}
});
} catch (err) {
error.value = err.message;
} finally {
isSaving.value = false;
}
};
/**
* Auto-guardar (debounced)
*/
let autoSaveTimeout = null;
const autoSave = () => {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(() => {
saveDocument();
}, 2000); // Guardar 2 segundos después del último cambio
};
/**
* Subir logo
*
* (usa FileReader temporalmente)
*
*/
const uploadLogo = async (file) => {
if (!file || !(file instanceof File)) {
error.value = 'Archivo inválido';
return;
}
isLoading.value = true;
error.value = null;
try {
// Cuando el backend esté listo, descomentar esto
/*
const config = documentService.uploadLogo(file);
const form = useForm({ logo: file });
await form.load({
...config,
options: {
onSuccess: (data) => {
branding.value.logoUrl = data.logoUrl;
branding.value.logoPreview = data.logoUrl;
},
onFail: (failData) => {
error.value = failData.message || 'Error al subir logo';
}
}
});
*/
// FALLBACK: Mientras no hay backend, usar FileReader
const reader = new FileReader();
reader.onload = (e) => {
branding.value.logoPreview = e.target.result;
branding.value.logo = file; // Guardar el archivo original
};
reader.onerror = () => {
error.value = 'Error al leer el archivo de imagen';
};
reader.readAsDataURL(file);
} catch (err) {
error.value = err.message || 'Error al procesar logo';
console.error('Error uploadLogo:', err);
} finally {
isLoading.value = false;
}
};
/**
* Generar PDF del documento
*
* Si el documento no está guardado, lo guarda primero
* y verifica que el guardado sea exitoso antes de generar PDF
*/
const generatePDF = async () => {
isLoading.value = true;
error.value = null;
try {
// Si no hay documentId, guardar primero
if (!documentId.value) {
// Guardar y esperar confirmación
await new Promise((resolve, reject) => {
saveDocument({
onSuccess: () => {
resolve();
},
onFail: (failData) => {
reject(new Error(failData.message || 'Error al guardar documento'));
}
});
});
// Verificar que ahora sí tenemos documentId
if (!documentId.value) {
throw new Error('No se pudo obtener el ID del documento guardado');
}
}
// Ahora generar PDF
const config = documentService.generatePDF(documentId.value);
const api = useApi();
await api.load({
...config,
options: {
onSuccess: (data) => {
if (data.pdfUrl) {
window.open(data.pdfUrl, '_blank');
} else {
error.value = 'No se recibió la URL del PDF';
}
},
onFail: (failData) => {
error.value = failData.message || 'Error al generar PDF';
}
}
});
} catch (err) {
error.value = err.message || 'Error al generar PDF';
console.error('Error generatePDF:', err);
} finally {
isLoading.value = false;
}
};
// ============================================
// WATCHERS
// ============================================
// Sincronizar documentType con branding.documentType
watch(documentType, (newType) => {
branding.value.documentType = newType;
});
// Auto-save cuando cambian los datos (solo si ya existe documentId)
watch([formData, productos, totales], () => {
if (documentId.value) {
autoSave();
}
}, { deep: true });
// ============================================
// RETORNO
// ============================================
return {
// Estado
documentId,
documentType,
document,
formData,
branding,
productos,
totales,
isLoading,
isSaving,
error,
// Computed
documentPayload,
// Métodos
initializeForm,
loadDocument,
saveDocument,
uploadLogo,
generatePDF,
autoSave
};
}

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 -->

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,176 @@
export default {
templateId: 'temp-cot-001',
nombre: 'Cotización',
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: 'empresaEmail',
label: 'Email',
tipo: 'email',
required: true,
placeholder: 'contacto@ejemplo.com',
},
{
key: 'empresaTelefono',
label: 'Teléfono',
tipo: 'tel',
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: 'Ej: Juan Pérez',
},
{
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

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

View File

@ -0,0 +1,95 @@
export default{
templateId: 'temp-rem-001',
nombre: 'Remisión',
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: 'empresaRFC',
label: 'RFC',
tipo: 'text',
required: true,
placeholder: 'Ej: ABC123456789',
},
{
key: 'empresaDireccion',
label: 'Dirección',
tipo: 'textarea',
required: true,
placeholder: 'Dirección completa',
},
],
},
{
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: 'Ej: ABC123456789',
},
{
key: 'clienteTelefono',
label: 'Teléfono',
tipo: 'text',
required: false,
placeholder: 'Ej: 555-123-4567',
},
{
key: 'clienteDomicilio',
label: 'Dirección',
tipo: 'textarea',
required: true,
placeholder: 'Dirección completa',
},
]
},
{
seccion: 'Detalles 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: 'fechaRemision',
label: 'Fecha',
tipo: 'date',
required: true,
},
]
}
]
}

View File

@ -0,0 +1,85 @@
import { ref } from 'vue';
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas-pro';
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 width
const pdfHeight = 297; // A4 height
// 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');
// Guardar PDF
pdf.save(filename);
Notify.success('PDF generado exitosamente');
} catch (error) {
Notify.error(`Error al generar el PDF: ${error.message}`);
} finally {
isExporting.value = false;
}
};
return {
exportToPDF,
isExporting
};
}

View File

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

View File

@ -0,0 +1,476 @@
<script setup>
import { ref, computed, watch, onMounted } from "vue";
import { usePDFExport } from "@Pages/Templates/Configs/usePDFExport";
import { useDocument } from "@/composables/useDocument";
import ConfigCotizacion from "@Pages/Templates/Configs/ConfigCotizacion.js";
import ConfigFacturacion from "@Pages/Templates/Configs/ConfigFacturacion.js";
import ConfigRemision from "@Pages/Templates/Configs/ConfigRemision.js";
import Document from "./DocumentTemplate.vue";
import ProductTable from "@Holos/DocumentSection/CotizacionTable.vue";
import FacturaTable from "@Holos/DocumentSection/FacturacionTable.vue";
import RemisionTable from "@Holos/DocumentSection/RemisionTable.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";
/** Props (opcional: para modo edición) */
const props = defineProps({
id: {
type: [String, Number],
default: null
},
type: {
type: String,
default: 'COTIZACION'
}
});
/** Composables */
const { exportToPDF, isExporting } = usePDFExport();
const {
documentId,
documentType,
formData,
branding,
productos,
totales,
isLoading,
isSaving,
error,
initializeForm,
loadDocument,
saveDocument,
uploadLogo,
generatePDF
} = useDocument({
documentId: props.id,
documentType: props.type
});
/** Estado Local */
const templateConfig = ref(ConfigCotizacion);
const showPreview = ref(true);
/** Computed */
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 handleLogoUpload = async (file) => {
if (!file) {
console.warn("No se seleccionó archivo");
return;
}
console.log("Archivo seleccionado:", file.name, file.type);
await uploadLogo(file);
};
const handleTotalsUpdate = (newTotals) => {
totales.value = { ...newTotals };
};
const handleExport = () => {
exportToPDF(
"template-preview",
`${branding.value.documentType}-${formData.value.folio || "documento"}.pdf`
);
};
const handleSave = async () => {
await saveDocument({
onSuccess: (data) => {
console.log('Documento guardado:', data);
},
onFail: (err) => {
console.error('Error al guardar:', err);
}
});
};
const handleGeneratePDF = async () => {
await generatePDF();
};
/** Watchers */
watch(
() => branding.value.documentType,
(newType) => {
if (newType === "FACTURA") {
templateConfig.value = ConfigFacturacion;
} else if (newType === "REMISION") {
templateConfig.value = ConfigRemision;
} else {
templateConfig.value = ConfigCotizacion;
}
initializeForm(templateConfig.value);
productos.value = [];
}
);
/** Ciclos de vida */
onMounted(async () => {
// Si hay ID, cargar documento existente
if (props.id) {
await loadDocument(props.id);
// Actualizar templateConfig según el tipo cargado
if (documentType.value === "FACTURA") {
templateConfig.value = ConfigFacturacion;
} else if (documentType.value === "REMISION") {
templateConfig.value = ConfigRemision;
} else {
templateConfig.value = ConfigCotizacion;
}
} else {
// Nuevo documento: inicializar formulario vacío
initializeForm(templateConfig.value);
}
});
</script>
<template>
<div class="p-6">
<!-- Loading overlay -->
<div
v-if="isLoading"
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"
>
<div class="bg-white dark:bg-primary-d rounded-lg p-6 flex items-center gap-3">
<GoogleIcon name="hourglass_empty" class="animate-spin text-2xl text-blue-600" />
<span class="text-gray-900 dark:text-primary-dt font-medium">
Cargando documento...
</span>
</div>
</div>
<!-- Error message -->
<div
v-if="error"
class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 dark:bg-red-900/20 dark:border-red-700"
>
<GoogleIcon name="error" class="text-red-600 dark:text-red-400 text-xl" />
<div class="flex-1">
<p class="text-red-800 dark:text-red-300 font-medium">Error</p>
<p class="text-red-600 dark:text-red-400 text-sm">{{ error }}</p>
</div>
<button
@click="error = null"
class="text-red-600 hover:text-red-800 dark:text-red-400"
>
<GoogleIcon name="close" />
</button>
</div>
<!-- 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
<span v-if="documentId" class="text-blue-600 dark:text-blue-400">
· ID: {{ documentId }}
</span>
<span v-if="isSaving" class="text-orange-600 dark:text-orange-400">
· Guardando...
</span>
</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>
<!-- Botón Guardar -->
<PrimaryButton
@click="handleSave"
:disabled="isSaving"
class="px-4 py-2 bg-green-600 hover:bg-green-700"
>
<GoogleIcon name="save" class="mr-2 text-sm" />
{{ isSaving ? "Guardando..." : "Guardar" }}
</PrimaryButton>
<!-- Botón Exportar PDF (cliente) -->
<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"
:disabled="!!documentId"
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
<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>
<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;
branding.logoUrl = 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"
/>
<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="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="">Seleccione una opción</option>
<option
v-for="opcion in campo.opciones"
:key="opcion.value"
:value="opcion.value"
>
{{ opcion.label }}
</option>
</select>
</div>
</template>
</div>
</div>
<!-- Tabla de Productos -->
<FacturaTable
v-if="branding.documentType === 'FACTURA'"
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
<RemisionTable
v-if="branding.documentType === 'REMISION'"
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
<ProductTable
v-else
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
</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>
</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,15 @@ const router = createRouter({
},
]
},
{
path: 'templates',
name: 'admin.templates.index',
meta: {
title: 'Plantillas',
icon: 'templates',
},
component: () => import('@Pages/Templates/Form.vue'),
},
]
},
{

View File

@ -0,0 +1,102 @@
import { apiURL } from '@/services/Api';
/**
* Servicio para gestión de documentos
*/
export const documentService = {
/**
* Guardar documento (crear o actualizar)
*
*/
save(payload, documentId = null) {
if (!payload || typeof payload !== 'object') {
throw new Error('Payload es requerido y debe ser un objeto');
}
if (documentId !== null && (typeof documentId !== 'number' && typeof documentId !== 'string')) {
throw new Error('documentId debe ser un número o string');
}
const method = documentId ? 'put' : 'post';
const url = documentId
? apiURL(`documents/${documentId}`)
: apiURL('documents');
return {
method,
url,
data: payload
};
},
/**
* Obtener documento por ID
*
*/
getById(documentId) {
return {
method: 'get',
url: apiURL(`documents/${documentId}`)
};
},
/**
* Listar documentos con filtros
*
*/
list(filters = {}) {
return {
method: 'get',
url: apiURL('documents'),
params: filters
};
},
/**
* Generar PDF del documento
*
*/
generatePDF(documentId, options = {}) {
if (!documentId) {
throw new Error('Id es requerido');
}
const config = {
method: 'post',
url: apiURL(`documents/${documentId}/generate-pdf`)
};
if (Object.keys(options).length > 0) {
config.data = {
format: options.format || 'A4',
orientation: options.orientation || 'portrait'
};
}
return config;
},
/**
* Subir logo
*
*/
uploadLogo(file) {
if (!(file instanceof File)) {
throw new Error('El parámetro debe ser un archivo (File)');
}
return {
method: 'post',
url: apiURL('documents/upload-logo'),
data: { logo: file }
};
},
/**
* Eliminar documento
*/
delete(documentId) {
return {
method: 'delete',
url: apiURL(`documents/${documentId}`)
};
}
};

273
src/types/documents.d.ts vendored Normal file
View File

@ -0,0 +1,273 @@
/**
* Tipos y interfaces para el sistema de documentos
*
* Este archivo define los contratos de datos entre frontend y backend
* para el módulo de generación de documentos (Cotizaciones, Facturas, Remisiones)
*
*/
/**
* Tipos de documentos soportados
*/
export type DocumentType = 'COTIZACION' | 'FACTURA' | 'REMISION';
/**
* Estados posibles de un documento
*/
export type DocumentStatus = 'BORRADOR' | 'FINALIZADO' | 'ENVIADO' | 'CANCELADO';
/**
* Régimen fiscal
*/
export type RegimenFiscal =
| 'Régimen Simplificado de Confianza'
| 'Personas Físicas con Actividades Empresariales y Profesionales';
/**
* Tipo de comprobante (para facturas CFDI)
*/
export type TipoComprobante = 'Ingreso' | 'Egreso' | 'Traslado';
/**
* Configuración de branding/tema del documento
*/
export interface DocumentTemplate {
documentType?: DocumentType; // Para compatibilidad con código actual
primaryColor: string;
secondaryColor?: string;
logoUrl?: string; // URL del logo almacenado en servidor
logo?: string | File; // Temporal para upload (Base64 o File)
logoPreview?: string; // Preview en frontend
slogan?: string;
}
/**
* Datos de la empresa emisora
*/
export interface EmpresaData {
nombre: string;
rfc: string;
email: string;
telefono: string;
direccion: string;
web?: string;
// Específico para facturas (CFDI)
lugar?: string; // Lugar de expedición (código postal)
cfdi?: string; // Uso de CFDI (ej: "G03 - Gastos en general")
regimen?: RegimenFiscal; // Régimen fiscal
}
/**
* Datos bancarios (opcional, principalmente para cotizaciones)
*/
export interface BancosData {
banco?: string;
tipoCuenta?: string; // Ej: "Cuenta de cheques"
cuenta?: string; // Número de cuenta
}
/**
* Datos del cliente receptor
*/
export interface ClienteData {
nombre: string;
rfc?: string;
domicilio?: string;
telefono?: string;
// Específico para facturas (CFDI)
regimen?: RegimenFiscal; // Régimen fiscal del cliente
}
/**
* Datos del ejecutivo de ventas (solo cotizaciones)
*/
export interface EjecutivoData {
nombre?: string;
correo?: string;
celular?: string;
}
/**
* Detalles específicos del documento
*/
export interface DocumentoDetalles {
// Común para todos
folio?: string;
observaciones?: string;
// Específico para cotizaciones
fechaRealizacion?: string;
vigencia?: string;
// Específico para facturas
serie?: string;
fechaEmision?: string;
tipoComprobante?: TipoComprobante;
// Específico para remisiones
fechaRemision?: string;
}
/**
* Producto en la tabla de productos
*/
export interface Producto {
id?: number; // ID
clave?: string; // Clave del producto/servicio
descripcion: string;
cantidad: number;
unidad?: string; // Unidad de medida (ej: "Pieza", "Servicio", "Hora")
precioUnitario: number;
descuento?: number; // Porcentaje de descuento
iva?: number; // Porcentaje de IVA
// Calculados (pueden venir del frontend o backend)
subtotal?: number;
total?: number;
}
/**
* Totales calculados del documento
*/
export interface Totales {
// Totales principales
subtotal: number;
iva: number;
total: number;
// Totales adicionales (opcionales)
subtotal1?: number; // Subtotal antes de descuento
descuentoTotal?: number; // Total de descuentos aplicados
subtotal2?: number; // Subtotal después de descuento
impuestosTrasladados?: number; // Para facturas
}
/**
* Estructura completa de datos del documento en la BD
*
* Este es el formato JSON que se guarda en la base de datos
* y el que el backend enviará/recibirá
*/
export interface DocumentRecord {
// Identificadores
id?: string | number;
tipo: DocumentType;
estado: DocumentStatus;
folio: string;
// Configuración de template/branding
templateConfig: DocumentTemplate;
// Datos agrupados del documento
datos: {
empresa: EmpresaData;
bancos?: BancosData; // Solo para cotizaciones
cliente: ClienteData;
ejecutivo?: EjecutivoData; // Solo para cotizaciones
documento: DocumentoDetalles;
productos: Producto[];
totales: Totales;
};
// URLs de archivos generados
pdfUrl?: string;
logoUrl?: string;
// Metadatos
userId?: string | number; // ID del usuario que creó el documento
createdAt?: string;
updatedAt?: string;
}
/**
* Payload para crear o actualizar un documento
*
* Este es el objeto que el frontend envía al backend
*/
export interface SaveDocumentPayload {
tipo: DocumentType;
estado?: DocumentStatus;
templateConfig: DocumentTemplate;
datos: DocumentRecord['datos'];
}
/**
* Respuesta del backend al guardar un documento
*/
export interface SaveDocumentResponse {
status: 'success' | 'fail' | 'error';
data: DocumentRecord;
message?: string;
}
/**
* Payload para generar PDF
*/
export interface GeneratePDFPayload {
documentId: string | number;
options?: {
format?: 'A4' | 'Letter';
orientation?: 'portrait' | 'landscape';
};
}
/**
* Respuesta al generar PDF
*/
export interface GeneratePDFResponse {
status: 'success' | 'fail' | 'error';
data: {
pdfUrl: string;
};
message?: string;
}
/**
* Payload para subir logo
*/
export interface UploadLogoPayload {
logo: File;
documentType?: DocumentType;
}
/**
* Respuesta al subir logo
*/
export interface UploadLogoResponse {
status: 'success' | 'fail' | 'error';
data: {
logoUrl: string;
};
message?: string;
}
/**
* Filtros para listar documentos
*/
export interface DocumentFilters {
tipo?: DocumentType;
estado?: DocumentStatus;
fechaInicio?: string;
fechaFin?: string;
search?: string; // Búsqueda por folio, cliente, etc.
page?: number;
perPage?: number;
}
/**
* Respuesta de listado de documentos
*/
export interface DocumentListResponse {
status: 'success' | 'fail' | 'error';
data: {
data: DocumentRecord[];
pagination: {
total: number;
perPage: number;
currentPage: number;
lastPage: number;
};
};
}

View File

@ -24,6 +24,10 @@ export default defineConfig({
'@Shared': fileURLToPath(new URL('./src/components/Shared', import.meta.url)),
'@Services': fileURLToPath(new URL('./src/services', import.meta.url)),
'@Stores': fileURLToPath(new URL('./src/stores', import.meta.url)),
}
}
},
dedupe: ['redi'],
},
optimizeDeps: {
include: ['@univerjs/preset-docs-core', '@univerjs/presets'],
},
})