Compare commits
15 Commits
main
...
dockerconf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0e2e7333 | ||
|
|
47764891d2 | ||
|
|
f277c3677a | ||
|
|
d7887d028c | ||
|
|
4518be3887 | ||
|
|
1ce1fb30fc | ||
|
|
b2095e4559 | ||
|
|
21f5d3a761 | ||
| 72d4423d67 | |||
| 9fbcc76638 | |||
|
|
a6abe2de40 | ||
| 19ae058e2d | |||
| 433994cda2 | |||
| 703b39e052 | |||
|
|
5e56c71bca |
@ -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
|
||||
|
||||
@ -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
2488
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
54
src/components/Holos/DocumentSection/ClientSection.vue
Normal file
54
src/components/Holos/DocumentSection/ClientSection.vue
Normal 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>
|
||||
67
src/components/Holos/DocumentSection/CompanyInfoSection.vue
Normal file
67
src/components/Holos/DocumentSection/CompanyInfoSection.vue
Normal 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>
|
||||
320
src/components/Holos/DocumentSection/CotizacionTable.vue
Normal file
320
src/components/Holos/DocumentSection/CotizacionTable.vue
Normal file
@ -0,0 +1,320 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:totals']);
|
||||
|
||||
const productos = ref([...props.modelValue]);
|
||||
|
||||
const unidades = [
|
||||
{ codigo: 'H87', nombre: 'Pieza' },
|
||||
{ codigo: 'E48', nombre: 'Unidad de servicio' },
|
||||
{ codigo: 'ACT', nombre: 'Actividad' },
|
||||
{ codigo: 'KGM', nombre: 'Kilogramo' },
|
||||
{ codigo: 'LTR', nombre: 'Litro' },
|
||||
{ codigo: 'MTR', nombre: 'Metro' },
|
||||
{ codigo: 'SET', nombre: 'Conjunto' }
|
||||
];
|
||||
|
||||
// Agregar producto vacío
|
||||
const agregarProducto = () => {
|
||||
const nuevoLote = productos.value.length + 1;
|
||||
productos.value.push({
|
||||
lote: nuevoLote,
|
||||
cantidad: 1,
|
||||
unidad: 'H87',
|
||||
codigo: '',
|
||||
descripcion: '',
|
||||
precioUnitario: 0,
|
||||
descuento: 0,
|
||||
tasaIVA: 0.16,
|
||||
});
|
||||
actualizarProductos();
|
||||
};
|
||||
|
||||
// Eliminar producto
|
||||
const eliminarProducto = (index) => {
|
||||
productos.value.splice(index, 1);
|
||||
// Reordenar lotes
|
||||
productos.value.forEach((producto, i) => {
|
||||
producto.lote = i + 1;
|
||||
});
|
||||
actualizarProductos();
|
||||
};
|
||||
|
||||
// Calcular importe de un producto
|
||||
const calcularImporte = (producto) => {
|
||||
return producto.cantidad * producto.precioUnitario;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcular subtotal (Importe - Descuento)
|
||||
*/
|
||||
const calcularSubtotal = (producto) => {
|
||||
return calcularImporte(producto) - (producto.descuento || 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcular impuesto de la partida
|
||||
* Solo si objetoImpuesto === '02' (Sí objeto de impuesto)
|
||||
*/
|
||||
const calcularImpuesto = (producto) => {
|
||||
if (producto.objetoImpuesto !== '02') return 0;
|
||||
const subtotal = calcularSubtotal(producto);
|
||||
return subtotal * (producto.tasaIVA || 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Total de la partida (Subtotal + Impuesto)
|
||||
*/
|
||||
const calcularTotalPartida = (producto) => {
|
||||
return calcularSubtotal(producto) + calcularImpuesto(producto);
|
||||
};
|
||||
|
||||
// Cálculos generales
|
||||
const calculos = computed(() => {
|
||||
const subtotal1 = productos.value.reduce((sum, p) => sum + calcularImporte(p), 0);
|
||||
const descuentoTotal = productos.value.reduce((sum, p) => sum + (p.descuento || 0), 0);
|
||||
const subtotal2 = subtotal1 - descuentoTotal;
|
||||
const iva = productos.value.reduce((sum, p) => sum + calcularImpuesto(p), 0);
|
||||
const total = subtotal2 + iva;
|
||||
|
||||
return {
|
||||
subtotal1,
|
||||
descuentoTotal,
|
||||
subtotal2,
|
||||
iva,
|
||||
total
|
||||
};
|
||||
});
|
||||
|
||||
// Actualizar productos y emitir cambios
|
||||
const actualizarProductos = () => {
|
||||
emit('update:modelValue', productos.value);
|
||||
emit('update:totals', calculos.value);
|
||||
};
|
||||
|
||||
// Watch para actualizar cuando cambian los productos
|
||||
watch(productos, () => {
|
||||
actualizarProductos();
|
||||
}, { deep: true });
|
||||
|
||||
// Watch para sincronizar con el v-model externo
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(productos.value)) {
|
||||
productos.value = [...newVal];
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
// Formatear moneda
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(value || 0);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-primary-dt">
|
||||
Productos / Servicios
|
||||
</h3>
|
||||
<button
|
||||
@click="agregarProducto"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<GoogleIcon name="add" class="text-lg" />
|
||||
Agregar Producto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Productos -->
|
||||
<div v-if="productos.length > 0" class="space-y-3">
|
||||
<div v-for="(producto, index) in productos" :key="producto.lote" class="border border-gray-200 rounded-lg p-4 dark:border-primary/20">
|
||||
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-blue-100 text-blue-700 font-bold px-3 py-1 rounded text-sm dark:bg-blue-900/30 dark:text-blue-300">
|
||||
#{{ producto.lote }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="eliminarProducto(index)"
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-800 transition-colors"
|
||||
>
|
||||
<GoogleIcon name="delete" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- CANTIDAD -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Cantidad <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="producto.cantidad"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1.00"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
</div>
|
||||
<!-- UNIDAD -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Unidad <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="producto.unidad"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option v-for="unidad in unidades" :key="unidad.codigo" :value="unidad.codigo">
|
||||
{{ unidad.nombre }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- CÓDIGO INTERNO -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Código Interno
|
||||
</label>
|
||||
<input
|
||||
v-model="producto.codigo"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="SKU-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- DESCRIPCIÓN -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Descripción del Concepto <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="producto.descripcion"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500 resize-y"
|
||||
placeholder="Descripción detallada del producto o servicio..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4">
|
||||
<!-- PRECIO UNITARIO -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Precio Unit. <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="producto.precioUnitario"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- IMPORTE -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Importe
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-right font-medium dark:bg-primary/5 dark:border-primary/20 dark:text-primary-dt">
|
||||
{{ formatCurrency(calcularImporte(producto)) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESCUENTO -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Descuento
|
||||
</label>
|
||||
<input
|
||||
v-model.number="producto.descuento"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- SUBTOTAL -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Subtotal
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-right font-semibold dark:bg-primary/5 dark:border-primary/20 dark:text-primary-dt">
|
||||
{{ formatCurrency(calcularSubtotal(producto)) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TASA IVA -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Tasa IVA
|
||||
</label>
|
||||
<select
|
||||
v-model.number="producto.tasaIVA"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="0.16">16%</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- TOTAL PARTIDA -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Total Partida
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-blue-50 border-2 border-blue-200 rounded-lg text-sm text-right font-bold text-blue-700 dark:bg-blue-900/20 dark:border-blue-700 dark:text-blue-400">
|
||||
{{ formatCurrency(calcularTotalPartida(producto)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resumen de totales -->
|
||||
<div v-if="productos.length > 0" class="mt-4 flex justify-end">
|
||||
<div class="w-64 space-y-2 border-t border-gray-200 dark:border-primary/20 pt-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-primary-dt/70">Subtotal 1:</span>
|
||||
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.subtotal1) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-primary-dt/70">Descuento:</span>
|
||||
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.descuentoTotal) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-primary-dt/70">Subtotal 2:</span>
|
||||
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.subtotal2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-primary-dt/70">IVA (16%):</span>
|
||||
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.iva) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-base border-t border-gray-200 dark:border-primary/20 pt-2">
|
||||
<span class="font-bold text-gray-900 dark:text-primary-dt">Total:</span>
|
||||
<span class="font-bold text-blue-600 dark:text-blue-400">{{ formatCurrency(calculos.total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
37
src/components/Holos/DocumentSection/ExecutiveSection.vue
Normal file
37
src/components/Holos/DocumentSection/ExecutiveSection.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="text-white font-semibold px-2 py-0.5"
|
||||
:style="{ backgroundColor: template.primaryColor }"
|
||||
>
|
||||
DATOS DEL EJECUTIVO DE CUENTAS
|
||||
</div>
|
||||
<div class="border border-gray-300 p-1.5">
|
||||
<p>
|
||||
<span class="font-semibold">NOMBRE:</span>
|
||||
{{ data.ejecutivoNombre }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-semibold">CORREO:</span>
|
||||
{{ data.ejecutivoCorreo }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-semibold">CELULAR:</span>
|
||||
{{ data.ejecutivoCelular }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
469
src/components/Holos/DocumentSection/FacturacionTable.vue
Normal file
469
src/components/Holos/DocumentSection/FacturacionTable.vue
Normal file
@ -0,0 +1,469 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import GoogleIcon from "@Shared/GoogleIcon.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:totals"]);
|
||||
|
||||
const productos = ref([...props.modelValue]);
|
||||
|
||||
// CATÁLOGOS SAT COMUNES
|
||||
const unidadesSAT = [
|
||||
{ codigo: "H87", nombre: "Pieza" },
|
||||
{ codigo: "E48", nombre: "Unidad de servicio" },
|
||||
{ codigo: "ACT", nombre: "Actividad" },
|
||||
{ codigo: "KGM", nombre: "Kilogramo" },
|
||||
{ codigo: "LTR", nombre: "Litro" },
|
||||
{ codigo: "MTR", nombre: "Metro" },
|
||||
{ codigo: "SET", nombre: "Conjunto" },
|
||||
];
|
||||
|
||||
const objetosImpuesto = [
|
||||
{ codigo: "01", nombre: "No objeto de impuesto" },
|
||||
{ codigo: "02", nombre: "Sí objeto de impuesto" },
|
||||
{ codigo: "03", nombre: "Sí objeto, no obligado" },
|
||||
{ codigo: "04", nombre: "Sí objeto, tasa 0%" },
|
||||
];
|
||||
/**
|
||||
* Agregar nuevo producto con estructura CFDI
|
||||
*/
|
||||
const agregarProducto = () => {
|
||||
const nuevoLote = productos.value.length + 1;
|
||||
productos.value.push({
|
||||
// Datos básicos
|
||||
lote: nuevoLote,
|
||||
cantidad: 1,
|
||||
|
||||
// Catálogos SAT
|
||||
unidadSAT: "H87", // Código de unidad SAT
|
||||
claveProdServ: "", // Clave producto/servicio SAT (8 dígitos)
|
||||
|
||||
// Descripción
|
||||
codigo: "", // Código interno del producto
|
||||
descripcion: "",
|
||||
|
||||
// Precios
|
||||
precioUnitario: 0,
|
||||
descuento: 0,
|
||||
|
||||
// Impuestos
|
||||
objetoImpuesto: "02", // Sí objeto de impuesto
|
||||
tasaIVA: 0.16, // 16%
|
||||
});
|
||||
actualizarProductos();
|
||||
};
|
||||
|
||||
/**
|
||||
* Eliminar producto y reordenar lotes
|
||||
*/
|
||||
const eliminarProducto = (index) => {
|
||||
productos.value.splice(index, 1);
|
||||
// Reordenar lotes
|
||||
productos.value.forEach((producto, i) => {
|
||||
producto.lote = i + 1;
|
||||
});
|
||||
actualizarProductos();
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcular importe bruto (Cantidad × Precio Unitario)
|
||||
*/
|
||||
const calcularImporte = (producto) => {
|
||||
return producto.cantidad * producto.precioUnitario;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcular subtotal (Importe - Descuento)
|
||||
*/
|
||||
const calcularSubtotal = (producto) => {
|
||||
return calcularImporte(producto) - (producto.descuento || 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcular impuesto de la partida
|
||||
* Solo si objetoImpuesto === '02' (Sí objeto de impuesto)
|
||||
*/
|
||||
const calcularImpuesto = (producto) => {
|
||||
if (producto.objetoImpuesto !== "02") return 0;
|
||||
const subtotal = calcularSubtotal(producto);
|
||||
return subtotal * (producto.tasaIVA || 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Total de la partida (Subtotal + Impuesto)
|
||||
*/
|
||||
const calcularTotalPartida = (producto) => {
|
||||
return calcularSubtotal(producto) + calcularImpuesto(producto);
|
||||
};
|
||||
|
||||
const calculos = computed(() => {
|
||||
// Suma de todos los importes
|
||||
const importeTotal = productos.value.reduce(
|
||||
(sum, p) => sum + calcularImporte(p),
|
||||
0
|
||||
);
|
||||
|
||||
// Suma de descuentos
|
||||
const descuentoTotal = productos.value.reduce(
|
||||
(sum, p) => sum + (p.descuento || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Subtotal antes de impuestos
|
||||
const subtotal = importeTotal - descuentoTotal;
|
||||
|
||||
// Total de impuestos trasladados (IVA)
|
||||
const impuestosTrasladados = productos.value.reduce(
|
||||
(sum, p) => sum + calcularImpuesto(p),
|
||||
0
|
||||
);
|
||||
|
||||
// Total a pagar
|
||||
const total = subtotal + impuestosTrasladados;
|
||||
|
||||
return {
|
||||
importeTotal, // Para validaciones
|
||||
descuentoTotal, // Total descuentos
|
||||
subtotal, // Subtotal sin impuestos
|
||||
impuestosTrasladados, // Total IVA
|
||||
total, // Total final
|
||||
};
|
||||
});
|
||||
|
||||
const actualizarProductos = () => {
|
||||
emit("update:modelValue", productos.value);
|
||||
emit("update:totals", calculos.value);
|
||||
};
|
||||
|
||||
// Watch profundo para detectar cambios en productos
|
||||
watch(
|
||||
productos,
|
||||
() => {
|
||||
actualizarProductos();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Sincronizar con v-model externo
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(productos.value)) {
|
||||
productos.value = [...newVal];
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat("es-MX", {
|
||||
style: "currency",
|
||||
currency: "MXN",
|
||||
}).format(value || 0);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20"
|
||||
>
|
||||
<!-- ENCABEZADO -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-primary-dt">
|
||||
Conceptos CFDI
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-primary-dt/60">
|
||||
Productos/Servicios conforme a catálogos SAT
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="agregarProducto"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<GoogleIcon name="add" class="text-lg" />
|
||||
Agregar Concepto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Productos -->
|
||||
<div v-if="productos.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(producto, index) in productos"
|
||||
:key="producto.lote"
|
||||
class="border-2 border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors dark:border-primary/20 dark:hover:border-blue-500"
|
||||
>
|
||||
<!-- Header de la tarjeta -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="bg-blue-100 text-blue-700 font-bold px-3 py-1 rounded text-sm dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
#{{ producto.lote }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="eliminarProducto(index)"
|
||||
type="button"
|
||||
class="text-red-500 hover:text-red-700 p-2 hover:bg-red-50 rounded-lg transition-colors dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
title="Eliminar concepto"
|
||||
>
|
||||
<GoogleIcon name="delete" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grid de campos -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- CANTIDAD -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Cantidad <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="producto.cantidad"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1.00"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- UNIDAD SAT -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Unidad SAT <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="producto.unidadSAT"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option
|
||||
v-for="unidad in unidadesSAT"
|
||||
:key="unidad.codigo"
|
||||
:value="unidad.codigo"
|
||||
>
|
||||
{{ unidad.codigo }} - {{ unidad.nombre }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- CLAVE PROD/SERV SAT -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Clave Prod/Serv SAT <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="producto.claveProdServ"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="12345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- CÓDIGO INTERNO -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Código Interno
|
||||
</label>
|
||||
<input
|
||||
v-model="producto.codigo"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="SKU-001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESCRIPCIÓN -->
|
||||
<div class="mt-4">
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Descripción del Concepto <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="producto.descripcion"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500 resize-y"
|
||||
placeholder="Descripción detallada del producto o servicio..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Grid de precios e impuestos -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4">
|
||||
<!-- PRECIO UNITARIO -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Precio Unit. <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="producto.precioUnitario"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- IMPORTE -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Importe
|
||||
</label>
|
||||
<div
|
||||
class="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-right font-medium dark:bg-primary/5 dark:border-primary/20 dark:text-primary-dt"
|
||||
>
|
||||
{{ formatCurrency(calcularImporte(producto)) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESCUENTO -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Descuento
|
||||
</label>
|
||||
<input
|
||||
v-model.number="producto.descuento"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- SUBTOTAL -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Subtotal
|
||||
</label>
|
||||
<div
|
||||
class="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-right font-semibold dark:bg-primary/5 dark:border-primary/20 dark:text-primary-dt"
|
||||
>
|
||||
{{ formatCurrency(calcularSubtotal(producto)) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TASA IVA -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Tasa IVA
|
||||
</label>
|
||||
<select
|
||||
v-model.number="producto.tasaIVA"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="0">0%</option>
|
||||
<option :value="0.08">8%</option>
|
||||
<option :value="0.16">16%</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- TOTAL PARTIDA -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
|
||||
>
|
||||
Total Partida
|
||||
</label>
|
||||
<div
|
||||
class="px-3 py-2 bg-blue-50 border-2 border-blue-200 rounded-lg text-sm text-right font-bold text-blue-700 dark:bg-blue-900/20 dark:border-blue-700 dark:text-blue-400"
|
||||
>
|
||||
{{ formatCurrency(calcularTotalPartida(producto)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estado vacío -->
|
||||
<div v-else class="text-center py-12 text-gray-500 dark:text-primary-dt/70">
|
||||
<GoogleIcon name="receipt_long" class="text-5xl mb-3 opacity-50" />
|
||||
<p class="text-sm font-semibold">No hay conceptos agregados</p>
|
||||
<p class="text-xs">Haz clic en "Agregar Concepto" para comenzar</p>
|
||||
</div>
|
||||
|
||||
<!-- RESUMEN DE TOTALES -->
|
||||
<div v-if="productos.length > 0" class="mt-6 flex justify-end">
|
||||
<div
|
||||
class="w-80 border-2 border-gray-300 rounded-lg overflow-hidden dark:border-primary/20"
|
||||
>
|
||||
<!-- Subtotal -->
|
||||
<div
|
||||
class="flex justify-between bg-gray-100 dark:bg-primary/10 px-4 py-3 border-b dark:border-primary/20"
|
||||
>
|
||||
<span class="font-semibold text-gray-700 dark:text-primary-dt"
|
||||
>Subtotal:</span
|
||||
>
|
||||
<span class="font-bold text-gray-900 dark:text-primary-dt text-lg">
|
||||
{{ formatCurrency(calculos.subtotal) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Descuento Total -->
|
||||
<div
|
||||
v-if="calculos.descuentoTotal > 0"
|
||||
class="flex justify-between px-4 py-2 border-b dark:border-primary/20"
|
||||
>
|
||||
<span class="text-sm text-gray-600 dark:text-primary-dt/70"
|
||||
>Descuentos:</span
|
||||
>
|
||||
<span class="text-sm text-red-600 dark:text-red-400 font-semibold">
|
||||
- {{ formatCurrency(calculos.descuentoTotal) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Impuestos Trasladados (IVA) -->
|
||||
<div
|
||||
class="flex justify-between bg-green-50 dark:bg-green-900/20 px-4 py-3 border-b dark:border-primary/20"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-semibold text-gray-700 dark:text-primary-dt"
|
||||
>
|
||||
Impuestos Trasladados:
|
||||
</span>
|
||||
<span class="font-bold text-green-700 dark:text-green-400 text-lg">
|
||||
{{ formatCurrency(calculos.impuestosTrasladados) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div
|
||||
class="flex justify-between bg-blue-600 dark:bg-blue-700 px-4 py-4"
|
||||
>
|
||||
<span class="font-bold text-white text-lg">TOTAL:</span>
|
||||
<span class="font-bold text-white text-2xl">
|
||||
{{ formatCurrency(calculos.total) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
35
src/components/Holos/DocumentSection/FooterSection.vue
Normal file
35
src/components/Holos/DocumentSection/FooterSection.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
totalEnLetras: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Total en letras -->
|
||||
<p v-if="totalEnLetras" class="text-center font-bold mb-1">
|
||||
{{ totalEnLetras }}
|
||||
</p>
|
||||
|
||||
<!-- Certificaciones -->
|
||||
<div
|
||||
class="text-white font-semibold px-2 py-0.5 text-center mb-1"
|
||||
:style="{ backgroundColor: template.primaryColor }"
|
||||
>
|
||||
CERTIFICACIONES Y PARTNERS
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center gap-4 flex-wrap">
|
||||
<div class="text-gray-500">
|
||||
Huawei | Lenovo | Hikvision | Fortinet | Panduit | Honeywell
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
91
src/components/Holos/DocumentSection/HeaderSection.vue
Normal file
91
src/components/Holos/DocumentSection/HeaderSection.vue
Normal 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>
|
||||
26
src/components/Holos/DocumentSection/ObservationsSection.vue
Normal file
26
src/components/Holos/DocumentSection/ObservationsSection.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="text-white font-semibold px-2 py-0.5"
|
||||
:style="{ backgroundColor: template.primaryColor }"
|
||||
>
|
||||
OBSERVACIONES
|
||||
</div>
|
||||
<div class="border border-gray-300 p-1.5 min-h-[50px]">
|
||||
{{ data.observaciones }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
93
src/components/Holos/DocumentSection/ProductsTableView.vue
Normal file
93
src/components/Holos/DocumentSection/ProductsTableView.vue
Normal 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>
|
||||
259
src/components/Holos/DocumentSection/RemisionTable.vue
Normal file
259
src/components/Holos/DocumentSection/RemisionTable.vue
Normal 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>
|
||||
117
src/components/Holos/DocumentSection/TotalsSection.vue
Normal file
117
src/components/Holos/DocumentSection/TotalsSection.vue
Normal file
@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
totales: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat("es-MX", {
|
||||
style: "currency",
|
||||
currency: "MXN",
|
||||
}).format(value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-end mb-3">
|
||||
<div class="border w-56" :style="{ borderColor: template.primaryColor }">
|
||||
<div class="grid grid-cols-2">
|
||||
|
||||
<!-- COTIZACIÓN -->
|
||||
<template v-if="template.documentType === 'COTIZACION'">
|
||||
<!-- SUBTOTAL 1 -->
|
||||
<div class="text-white font-semibold px-2 py-1 text-right"
|
||||
:style="{ backgroundColor: template.primaryColor }">
|
||||
SUBTOTAL 1:
|
||||
</div>
|
||||
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
|
||||
:style="{ borderColor: template.primaryColor }">
|
||||
{{ formatCurrency(totales.subtotal1) }}
|
||||
</div>
|
||||
|
||||
<!-- DESCUENTO -->
|
||||
<div class="text-white font-semibold px-2 py-1 text-right"
|
||||
:style="{ backgroundColor: template.primaryColor }">
|
||||
DESCUENTO:
|
||||
</div>
|
||||
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
|
||||
:style="{ borderColor: template.primaryColor }">
|
||||
{{ formatCurrency(totales.descuentoTotal) }}
|
||||
</div>
|
||||
|
||||
<!-- SUBTOTAL 2 -->
|
||||
<div class="text-white font-semibold px-2 py-1 text-right"
|
||||
:style="{ backgroundColor: template.primaryColor }">
|
||||
SUBTOTAL 2:
|
||||
</div>
|
||||
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
|
||||
:style="{ borderColor: template.primaryColor }">
|
||||
{{ formatCurrency(totales.subtotal2) }}
|
||||
</div>
|
||||
|
||||
<!-- I.V.A. -->
|
||||
<div class="text-white font-semibold px-2 py-1 text-right"
|
||||
:style="{ backgroundColor: template.primaryColor }">
|
||||
I.V.A.
|
||||
</div>
|
||||
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
|
||||
:style="{ borderColor: template.primaryColor }">
|
||||
{{ formatCurrency(totales.iva) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- FACTURA-->
|
||||
<template v-else-if="template.documentType === 'FACTURA'">
|
||||
<!-- SUBTOTAL -->
|
||||
<div class="text-white font-semibold px-2 py-1 text-right"
|
||||
:style="{ backgroundColor: template.primaryColor }">
|
||||
SUBTOTAL:
|
||||
</div>
|
||||
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
|
||||
:style="{ borderColor: template.primaryColor }">
|
||||
{{ formatCurrency(totales.subtotal) }}
|
||||
</div>
|
||||
|
||||
<!-- DESCUENTO (si existe) -->
|
||||
<template v-if="totales.descuentoTotal > 0">
|
||||
<div class="text-white font-semibold px-2 py-1 text-right"
|
||||
:style="{ backgroundColor: template.primaryColor }">
|
||||
DESCUENTO:
|
||||
</div>
|
||||
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
|
||||
:style="{ borderColor: template.primaryColor }">
|
||||
{{ formatCurrency(totales.descuentoTotal) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- IMPUESTOS TRASLADADOS -->
|
||||
<div class="text-white font-semibold px-2 py-1 text-right"
|
||||
:style="{ backgroundColor: template.primaryColor }">
|
||||
IMP. TRASLADADOS:
|
||||
</div>
|
||||
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
|
||||
:style="{ borderColor: template.primaryColor }">
|
||||
{{ formatCurrency(totales.impuestosTrasladados) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TOTAL (común para ambos) -->
|
||||
<div class="text-white font-bold px-2 py-1 text-right"
|
||||
:style="{ backgroundColor: template.primaryColor }">
|
||||
TOTAL
|
||||
</div>
|
||||
<div class="px-2 py-1 text-right font-bold whitespace-nowrap border-l"
|
||||
:style="{ borderColor: template.primaryColor }">
|
||||
{{ formatCurrency(totales.total) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,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>
|
||||
@ -1,650 +1,291 @@
|
||||
<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 = {
|
||||
left: `${props.element.x}px`,
|
||||
top: `${props.element.y}px`,
|
||||
width: `${props.element.width || 200}px`,
|
||||
height: `${props.element.height || 40}px`
|
||||
};
|
||||
const elementStyles = computed(() => ({
|
||||
left: `${props.element.x}px`,
|
||||
top: `${props.element.y}px`,
|
||||
width: `${props.element.width || 200}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);
|
||||
event.stopPropagation();
|
||||
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();
|
||||
});
|
||||
}
|
||||
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 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 handleContentUpdate = (newContent) => {
|
||||
emit("update", { id: props.element.id, content: newContent });
|
||||
};
|
||||
|
||||
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 handleEditorFocus = (editor) => {
|
||||
emit("editor-active", editor);
|
||||
};
|
||||
|
||||
// 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();
|
||||
if (isEditing.value || event.target.closest(".resize-handle")) return;
|
||||
event.preventDefault();
|
||||
if (!props.isSelected) emit("select", props.element.id);
|
||||
isDragging.value = true;
|
||||
dragStart.value = {
|
||||
mouseX: event.clientX,
|
||||
mouseY: event.clientY,
|
||||
elementX: props.element.x,
|
||||
elementY: props.element.y,
|
||||
};
|
||||
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) {
|
||||
handleResizeMove(event);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.value = false;
|
||||
isResizing.value = false;
|
||||
resizeDirection.value = null;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
isDragging.value = false;
|
||||
isResizing.value = false;
|
||||
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);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
if (isEditing.value) return;
|
||||
isResizing.value = true;
|
||||
resizeStart.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
width: elementRef.value.offsetWidth,
|
||||
height: elementRef.value.offsetHeight,
|
||||
};
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const handleResizeMove = (event) => {
|
||||
if (!isResizing.value) return;
|
||||
if (!isResizing.value) return;
|
||||
const deltaX = event.clientX - resizeStart.value.x;
|
||||
const deltaY = event.clientY - resizeStart.value.y;
|
||||
|
||||
const deltaX = event.clientX - resizeStart.value.x;
|
||||
const deltaY = event.clientY - resizeStart.value.y;
|
||||
// Límites
|
||||
const pageW = props.pageDimensions.width;
|
||||
const pageH = props.pageDimensions.height;
|
||||
const elX = props.element.x;
|
||||
const elY = props.element.y;
|
||||
|
||||
let newWidth = resizeStart.value.width;
|
||||
let newHeight = resizeStart.value.height;
|
||||
let newWidth = resizeStart.value.width + deltaX;
|
||||
let newHeight = resizeStart.value.height + deltaY;
|
||||
|
||||
// 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));
|
||||
}
|
||||
newWidth = Math.max(100, Math.min(newWidth, pageW - elX));
|
||||
newHeight = Math.max(40, Math.min(newHeight, pageH - elY));
|
||||
|
||||
emit('update', {
|
||||
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,
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
event.target.value = null;
|
||||
};
|
||||
|
||||
// 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"
|
||||
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"
|
||||
>
|
||||
<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',
|
||||
'z-50': isSelected,
|
||||
'z-10': !isSelected
|
||||
}"
|
||||
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"
|
||||
>
|
||||
<!-- Input oculto para selección de archivos -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@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>
|
||||
<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"
|
||||
accept="image/*"
|
||||
@change="handleFileSelect"
|
||||
class="hidden"
|
||||
/>
|
||||
</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);
|
||||
:deep(.table-preview p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -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>
|
||||
@ -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"
|
||||
<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 }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="totalPages > 1"
|
||||
@click="deletePage(pageIndex)"
|
||||
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"
|
||||
>
|
||||
<!-- 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">
|
||||
Página {{ pageIndex + 1 }}
|
||||
</span>
|
||||
<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"
|
||||
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="{
|
||||
'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`
|
||||
}"
|
||||
@drop="(e) => handleDrop(e, pageIndex)"
|
||||
@dragover="handleDragOver"
|
||||
@click="(e) => handleClick(e, pageIndex)"
|
||||
>
|
||||
<!-- Á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`
|
||||
}"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GoogleIcon name="delete" class="text-sm" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="pdf-page relative bg-white shadow-lg rounded-md border transition-all duration-200 overflow-hidden"
|
||||
:class="{
|
||||
'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="setCurrentPage(pageIndex + 1)"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ transform: `scale(${ZOOM_LEVEL})`, transformOrigin: 'top left' }"
|
||||
>
|
||||
<slot name="elements" :page="page" :dimensions="currentPageSize" />
|
||||
</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>
|
||||
</template>
|
||||
@ -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>
|
||||
|
||||
354
src/components/Holos/PDF/TableEditor.vue
Normal file
354
src/components/Holos/PDF/TableEditor.vue
Normal 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>
|
||||
512
src/components/Holos/PDF/TableEditorModal.vue
Normal file
512
src/components/Holos/PDF/TableEditorModal.vue
Normal 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>
|
||||
@ -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"
|
||||
>
|
||||
<!-- 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">
|
||||
<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)"
|
||||
>
|
||||
B
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<div
|
||||
class="w-full bg-white border-b px-4 py-2 shadow-sm h-14 flex items-center gap-2"
|
||||
>
|
||||
<template v-if="isTextSelected">
|
||||
<!-- Botones de formato de texto -->
|
||||
<template v-if="editor">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<GoogleIcon :name="item.icon" />
|
||||
</button>
|
||||
|
||||
<!-- Separador -->
|
||||
<div class="w-px h-6 bg-gray-300 dark:bg-primary/30"></div>
|
||||
<div class="w-px h-6 bg-gray-200 mx-1"></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>
|
||||
<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"
|
||||
<!-- Controles de tamaño y color para texto -->
|
||||
<div class="flex items-center gap-2" @mousedown.stop>
|
||||
<select
|
||||
v-model="currentFontSize"
|
||||
class="px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
>
|
||||
<option v-for="size in FONT_SIZES" :key="size" :value="size">
|
||||
{{ size }}px
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Selector de colores -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleColorGrid"
|
||||
class="w-8 h-6 border border-gray-300 rounded flex items-center justify-center"
|
||||
:style="{ backgroundColor: currentColour }"
|
||||
>
|
||||
<option v-for="size in fontSizes" :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">
|
||||
<GoogleIcon name="palette" class="text-xs text-white mix-blend-difference" />
|
||||
</button>
|
||||
<div
|
||||
ref="colorGrid"
|
||||
class="absolute top-full left-0 mt-1 p-2 bg-white border rounded shadow-lg z-50 hidden"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-1">
|
||||
<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"
|
||||
>
|
||||
<GoogleIcon name="format_align_left" class="text-sm" />
|
||||
</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>
|
||||
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>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<div v-else class="text-sm text-gray-400">
|
||||
Selecciona un elemento para ver sus opciones
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
128
src/components/Holos/PDF/TiptapEditor.vue
Normal file
128
src/components/Holos/PDF/TiptapEditor.vue
Normal 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>
|
||||
@ -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>
|
||||
155
src/components/Holos/TemplateForm.vue
Normal file
155
src/components/Holos/TemplateForm.vue
Normal 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>
|
||||
464
src/composables/useDocument.js
Normal file
464
src/composables/useDocument.js
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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
176
src/pages/Templates/Configs/ConfigCotizacion.js
Normal file
176
src/pages/Templates/Configs/ConfigCotizacion.js
Normal 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',
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
140
src/pages/Templates/Configs/ConfigFacturacion.js
Normal file
140
src/pages/Templates/Configs/ConfigFacturacion.js
Normal file
@ -0,0 +1,140 @@
|
||||
export default{
|
||||
templateId: 'temp-cot-002',
|
||||
nombre: 'Factura',
|
||||
|
||||
branding: {
|
||||
logo: null,
|
||||
primaryColor: '#2c50dd',
|
||||
slogan: ' ',
|
||||
},
|
||||
|
||||
campos:[
|
||||
{
|
||||
seccion: 'Datos de la Empresa',
|
||||
campos: [
|
||||
{
|
||||
key: 'empresaNombre',
|
||||
label: 'Nombre de la Empresa',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
placeholder: 'Ej: GOLSYSTEMS',
|
||||
},
|
||||
{
|
||||
key: 'empresaWeb',
|
||||
label: 'Sitio Web',
|
||||
tipo: 'url',
|
||||
required: false,
|
||||
placeholder: 'www.ejemplo.com',
|
||||
},
|
||||
{
|
||||
key: 'empresaDireccion',
|
||||
label: 'Dirección',
|
||||
tipo: 'textarea',
|
||||
required: true,
|
||||
placeholder: 'Dirección completa',
|
||||
},
|
||||
{
|
||||
key: 'empresaRFC',
|
||||
label: 'RFC',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
placeholder: 'GME111116GJA',
|
||||
},
|
||||
{
|
||||
key: 'empresaLugar',
|
||||
label: 'Lugar de Expedición',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
placeholder: '8000',
|
||||
},
|
||||
{
|
||||
key: 'empresaCfdi',
|
||||
label: 'Uso de CFDI',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
placeholder: 'G03 - Gastos en general',
|
||||
},
|
||||
{
|
||||
key: 'empresaRegimen',
|
||||
label: 'Régimen Fiscal',
|
||||
tipo: 'select',
|
||||
required: true,
|
||||
opciones: [
|
||||
{ value: 'Régimen Simplificado de Confianza', label: 'Régimen Simplificado de Confianza' },
|
||||
{ value: 'Personas Físicas con Actividades Empresariales y Profesionales', label: 'Personas Físicas con Actividades Empresariales y Profesionales' },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
seccion: 'Datos del Cliente',
|
||||
campos: [
|
||||
{
|
||||
key: 'clienteNombre',
|
||||
label: 'Nombre del Cliente',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
placeholder: 'Ej: Juan Pérez',
|
||||
},
|
||||
{
|
||||
key: 'clienteRFC',
|
||||
label: 'RFC',
|
||||
tipo: 'text',
|
||||
required: false,
|
||||
placeholder: 'RFC del cliente',
|
||||
},
|
||||
{
|
||||
key: 'clienteDomicilio',
|
||||
label: 'Domicilio',
|
||||
tipo: 'textarea',
|
||||
required: false,
|
||||
placeholder: 'Domicilio completo',
|
||||
},
|
||||
{
|
||||
key: 'clienteRegimen',
|
||||
label: 'Régimen Fiscal',
|
||||
tipo: 'select',
|
||||
required: true,
|
||||
opciones: [
|
||||
{ value: 'Régimen Simplificado de Confianza', label: 'Régimen Simplificado de Confianza' },
|
||||
{ value: 'Personas Físicas con Actividades Empresariales y Profesionales', label: 'Personas Físicas con Actividades Empresariales y Profesionales' },
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
seccion: 'Datos del Documento',
|
||||
campos: [
|
||||
{
|
||||
key: 'serie',
|
||||
label: 'Número de Serie',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'folio',
|
||||
label: 'Número de Folio',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'fechaEmision',
|
||||
label: 'Fecha de Emisión',
|
||||
tipo: 'date',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'tipoComprobante',
|
||||
label: 'Tipo de Comprobante',
|
||||
tipo: 'select',
|
||||
required: true,
|
||||
opciones: [
|
||||
{ value: 'Ingreso', label: 'Ingreso' },
|
||||
{ value: 'Egreso', label: 'Egreso' },
|
||||
{ value: 'Traslado', label: 'Traslado' },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
95
src/pages/Templates/Configs/ConfigRemision.js
Normal file
95
src/pages/Templates/Configs/ConfigRemision.js
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
85
src/pages/Templates/Configs/usePDFExport.js
Normal file
85
src/pages/Templates/Configs/usePDFExport.js
Normal 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
|
||||
};
|
||||
}
|
||||
66
src/pages/Templates/DocumentTemplate.vue
Normal file
66
src/pages/Templates/DocumentTemplate.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import HeaderSection from '@Holos/DocumentSection/HeaderSection.vue';
|
||||
import CompanyInfoSection from '@Holos/DocumentSection/CompanyInfoSection.vue';
|
||||
import ClientSection from '@Holos/DocumentSection/ClientSection.vue';
|
||||
import ExecutiveSection from '@Holos/DocumentSection/ExecutiveSection.vue';
|
||||
import ObservationsSection from '@Holos/DocumentSection/ObservationsSection.vue';
|
||||
import ProductsTableView from '@Holos/DocumentSection/ProductsTableView.vue';
|
||||
import TotalsSection from '@Holos/DocumentSection/TotalsSection.vue';
|
||||
import FooterSection from '@Holos/DocumentSection/FooterSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
documentData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const template = computed(() => props.documentData?.template || {
|
||||
documentType: 'COTIZACION',
|
||||
primaryColor: '#2c50dd',
|
||||
logo: null,
|
||||
slogan: 'Optimizando lasTIC\'s en las empresas'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-white p-4 text-[9px] leading-tight h-full"
|
||||
style="width: 100%; font-family: Arial, sans-serif; box-sizing: border-box"
|
||||
>
|
||||
<!-- Header -->
|
||||
<HeaderSection :template="template" :data="documentData" />
|
||||
|
||||
<!-- Datos Fiscales y Bancarios (solo cotización) -->
|
||||
<CompanyInfoSection
|
||||
:template="template"
|
||||
:data="documentData"
|
||||
/>
|
||||
|
||||
<!-- Cliente -->
|
||||
<ClientSection :template="template" :data="documentData" />
|
||||
|
||||
<!-- Ejecutivo y Observaciones (solo cotización) -->
|
||||
<div
|
||||
v-if="template.documentType === 'COTIZACION'"
|
||||
class="grid grid-cols-2 gap-3 mb-3"
|
||||
>
|
||||
<ExecutiveSection :template="template" :data="documentData" />
|
||||
<ObservationsSection :template="template" :data="documentData" />
|
||||
</div>
|
||||
|
||||
<!-- Tabla de Productos -->
|
||||
<ProductsTableView
|
||||
:template="template"
|
||||
:productos="documentData.productos || []"
|
||||
/>
|
||||
|
||||
<!-- Totales -->
|
||||
<TotalsSection :template="template" :totales="documentData" />
|
||||
|
||||
<!-- Footer -->
|
||||
<FooterSection :template="template" />
|
||||
</div>
|
||||
</template>
|
||||
476
src/pages/Templates/Form.vue
Normal file
476
src/pages/Templates/Form.vue
Normal 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>
|
||||
@ -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'),
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
102
src/services/documentService.js
Normal file
102
src/services/documentService.js
Normal 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
273
src/types/documents.d.ts
vendored
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -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'],
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user