Compare commits

...

15 Commits

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

View File

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

View File

@ -4,6 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>Holos</title>
</head> </head>
<body> <body>

2494
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,15 +13,30 @@
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@tailwindcss/postcss": "^4.0.9", "@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/vite": "^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", "@vitejs/plugin-vue": "^5.2.1",
"@vuepic/vue-datepicker": "^11.0.2",
"apexcharts": "^5.3.5", "apexcharts": "^5.3.5",
"axios": "^1.8.1", "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", "laravel-echo": "^2.0.2",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"pdf-lib": "^1.17.1",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"pusher-js": "^8.4.0", "pusher-js": "^8.4.0",
"tailwindcss": "^4.0", "tailwindcss": "^4.0",
"tiptap-extension-font-size": "^1.2.0",
"toastr": "^2.1.4", "toastr": "^2.1.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"v-calendar": "^3.1.2", "v-calendar": "^3.1.2",
@ -36,5 +51,8 @@
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"vite-plugin-html": "^3.2.2" "vite-plugin-html": "^3.2.2"
},
"overrides": {
"redi": "0.1.12"
} }
} }

View File

@ -0,0 +1,191 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { Calendar } from 'v-calendar';
import 'v-calendar/style.css';
// Props del componente
const props = defineProps({
// Días de conflicto a pintar en rojo
conflictDays: {
type: Array,
default: () => []
},
// Configuración adicional
locale: {
type: String,
default: 'es'
}
});
// Emits
const emit = defineEmits(['monthChanged']);
// Variables reactivas
const currentMonth = ref(new Date());
const fromPage = ref(null);
// Atributos para el calendario (eventos, vacaciones, etc.)
const attributes = computed(() => {
const attrs = [];
// Agregar días de conflicto (pintarlos en rojo)
if (props.conflictDays && props.conflictDays.length > 0) {
props.conflictDays.forEach((day) => {
const currentYear = currentMonth.value.getFullYear();
const currentMonthNumber = currentMonth.value.getMonth();
const conflictDate = new Date(currentYear, currentMonthNumber, day);
attrs.push({
key: `conflict-${day}`,
dates: conflictDate,
highlight: {
color: 'red',
fillMode: 'solid'
},
popover: {
label: `Día con conflictos de vacaciones`,
visibility: 'hover'
}
});
});
}
return attrs;
});
// Configuración del calendario
const calendarConfig = computed(() => ({
locale: props.locale,
firstDayOfWeek: 2, // Lunes
masks: {
weekdays: 'WWW',
navMonths: 'MMMM',
title: 'MMMM YYYY'
},
theme: {
isDark: false
}
}));
// Métodos
const onPageChanged = (page) => {
// Actualizar el mes actual cuando se cambia de página
currentMonth.value = new Date(page[0].year, page[0].month - 1, 1);
emit('monthChanged', page[0].month);
};
// Watch para re-renderizar cuando cambien los días de conflicto o el mes actual
watch([() => props.conflictDays, currentMonth], () => {
// Los attributes se recalcularán automáticamente al cambiar estas dependencias
}, { deep: true });
// Exponer métodos para uso externo
defineExpose({
currentMonth
});
</script>
<template>
<div class="calendar-container bg-white rounded-lg shadow-md p-4">
<!-- Header del calendario -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Calendario</h3>
</div>
<!-- Calendario principal -->
<div class="calendar-wrapper">
<Calendar
v-model="currentMonth"
:attributes="attributes"
:locale="calendarConfig.locale"
:first-day-of-week="calendarConfig.firstDayOfWeek"
:masks="calendarConfig.masks"
class="custom-calendar"
v-model:from-page="fromPage"
@update:from-page="onPageChanged"
@did-move="onPageChanged"
expanded
/>
</div>
</div>
</template>
<style scoped>
/* Estilos personalizados para el calendario */
.calendar-container {
min-width: 300px;
}
.custom-calendar {
width: 100%;
}
/* Personalización de v-calendar */
:deep(.vc-container) {
--vc-border-radius: 0.5rem;
--vc-weekday-color: #6B7280;
--vc-popover-content-bg: white;
--vc-popover-content-border: 1px solid #E5E7EB;
border: none;
font-family: inherit;
}
:deep(.vc-header) {
padding: 1rem 1rem 0.5rem;
}
:deep(.vc-title) {
font-size: 1rem;
font-weight: 600;
color: #1F2937;
}
:deep(.vc-weekday) {
color: #6B7280;
font-size: 0.75rem;
font-weight: 500;
padding: 0.5rem 0;
}
:deep(.vc-day) {
min-height: 2rem;
}
:deep(.vc-day-content) {
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
border: none;
font-weight: 500;
transition: all 0.2s ease;
}
:deep(.vc-day-content:hover) {
background-color: #EFF6FF;
color: #2563EB;
}
:deep(.vc-day-content.vc-day-content-today) {
background-color: #DBEAFE;
color: #1D4ED8;
font-weight: 600;
}
:deep(.vc-highlights .vc-highlight) {
border-radius: 0.375rem;
}
:deep(.vc-dots) {
margin-bottom: 0.125rem;
}
/* Responsive */
@media (max-width: 640px) {
.calendar-container {
min-width: auto;
margin: 0 -1rem;
border-radius: 0;
}
}
</style>

View File

@ -0,0 +1,54 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="mb-3">
<div
class="text-white font-semibold px-2 py-0.5"
:style="{ backgroundColor: template.primaryColor }"
>
DATOS FISCALES CLIENTE
</div>
<div class="border border-gray-300 p-1.5 grid grid-cols-2 gap-x-4">
<!-- Nombre del Cliente (todos los documentos) -->
<div>
<span class="font-semibold">Nombre:</span>
{{ data.clienteNombre }}
</div>
<!-- Domicilio (todos los documentos usan el mismo campo) -->
<div>
<span class="font-semibold">Domicilio:</span>
{{ data.clienteDomicilio }}
</div>
<!-- RFC (todos los documentos) -->
<div>
<span class="font-semibold">RFC:</span>
{{ data.clienteRFC }}
</div>
<!-- Teléfono (COTIZACION y REMISION) -->
<div v-if="template.documentType === 'COTIZACION' || template.documentType === 'REMISION'">
<span class="font-semibold">Teléfono:</span>
{{ data.clienteTelefono }}
</div>
<!-- Régimen Fiscal (solo FACTURA) -->
<div v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Régimen Fiscal:</span>
{{ data.clienteRegimen }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,67 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="mb-3">
<!-- Cotización -->
<div
v-if="template.documentType === 'COTIZACION'"
class="grid grid-cols-2 gap-1 mb-2"
>
<!-- Datos Fiscales -->
<div class="border border-gray-300 p-1.5">
<p class="font-semibold">{{ data.empresaNombre }}</p>
<p class="font-semibold">{{ data.empresaWeb }}</p>
<p>{{ data.empresaEmail }}</p>
<p>{{ data.empresaTelefono }}</p>
<p>RFC: {{ data.empresaRFC }}</p>
<p>{{ data.empresaDireccion }}</p>
</div>
<!-- Datos Bancarios -->
<div class="border border-gray-300 p-1.5">
<p class="font-semibold">{{ data.bancoBanco }}</p>
<p>{{ data.bancoTipoCuenta }}</p>
<p>Cuenta: {{ data.bancoCuenta }}</p>
</div>
</div>
<!-- Factura -->
<div v-else-if="template.documentType === 'FACTURA'">
<div class="border border-gray-300 p-1.5 grid grid-cols-2 gap-4">
<div>
<p class="font-semibold">{{ data.empresaNombre }}</p>
<p>{{ data.empresaWeb }}</p>
<p>{{ data.empresaDireccion }}</p>
</div>
<div>
<p>RFC: {{ data.empresaRFC }}</p>
<p>Lugar de Expedición: {{ data.empresaLugar }}</p>
<p>Régimen Fiscal: {{ data.empresaRegimen }}</p>
<p>Uso CFDI: {{ data.empresaCfdi }}</p>
</div>
</div>
</div>
<div
v-if="template.documentType === 'REMISION'"
class="grid grid-cols-2 gap-1 mb-2"
>
<!-- Datos Fiscales -->
<div class="border border-gray-300 p-1.5">
<p class="font-semibold">{{ data.empresaNombre }}</p>
<p>RFC: {{ data.empresaRFC }}</p>
<p>{{ data.empresaDireccion }}</p>
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,91 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
});
const getTipoFolioLabel = (documentType) => {
const labels = {
COTIZACION: "Número de Folio:",
FACTURA: "Folio:",
REMISION: "Folio:",
};
return labels[documentType] || "Número de Folio:";
};
</script>
<template>
<div
class="flex justify-between items-start pb-2 mb-3 border-b-2"
:style="{ borderColor: template.primaryColor }"
>
<div>
<!-- Logo -->
<img
v-if="template.logo"
:src="template.logo"
alt="Logo"
class="h-12 mb-1"
/>
<!-- Placeholder si no hay logo -->
<div
v-else
class="h-12 mb-1 flex items-center justify-center bg-gray-100 rounded text-gray-400 text-xs px-3"
>
Sin logo
</div>
<!-- Slogan -->
<p class="text-gray-600 mt-1">{{ template.slogan }}</p>
</div>
<div class="text-right">
<h2
class="text-xl font-bold mb-1"
:style="{ color: template.primaryColor }"
>
{{ template.documentType || "COTIZACION" }}
</h2>
<div class="space-y-0.5">
<p v-if="template.documentType === 'FACTURA' || template.documentType === 'REMISION'">
<span class="font-semibold"> Serie: </span>
{{ data.serie }}
</p>
<p>
<span class="font-semibold">{{
getTipoFolioLabel(template.documentType)
}}</span>
{{ data.folio }}
</p>
<p v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Fecha de Emisión:</span>
{{ data.fechaEmision }}
</p>
<p v-else-if="template.documentType === 'REMISION'">
<span class="font-semibold">Fecha de Remisión:</span>
{{ data.fechaRemision }}
</p>
<p v-else>
<span class="font-semibold">Fecha de Realización:</span>
{{ data.fechaRealizacion }}
</p>
<p v-if="template.documentType === 'COTIZACION'">
<span class="font-semibold">Vigencia:</span>
{{ data.vigencia }}
</p>
<p v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Tipo de Comprobante:</span>
{{ data.tipoComprobante }}
</p>
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

@ -0,0 +1,259 @@
<script setup>
import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update:totals']);
const productos = ref([...props.modelValue]);
const unidades = [
{ codigo: 'PIEZA', nombre: 'Pieza' },
{ codigo: 'CAJA', nombre: 'Caja' },
{ codigo: 'PAQUETE', nombre: 'Paquete' },
{ codigo: 'KG', nombre: 'Kilogramo' },
{ codigo: 'METRO', nombre: 'Metro' },
{ codigo: 'LITRO', nombre: 'Litro' },
];
/**
* Agregar nuevo producto
*/
const agregarProducto = () => {
productos.value.push({
cantidad: 1,
unidad: 'PIEZA',
codigo: '',
descripcion: '',
precioUnitario: 0,
});
actualizarProductos();
};
/**
* Eliminar producto
*/
const eliminarProducto = (index) => {
productos.value.splice(index, 1);
actualizarProductos();
};
/**
* Calcular importe total
*/
const calcularImporte = (producto) => {
return producto.cantidad * producto.precioUnitario;
};
/**
* Calcular totales generales
*/
const calculos = computed(() => {
const total = productos.value.reduce((sum, producto) => {
return sum + calcularImporte(producto);
}, 0);
return {
total,
subtotal: total,
iva: 0,
descuentoTotal: 0,
};
});
/**
* Actualizar productos y emitir cambios
*/
const actualizarProductos = () => {
emit('update:modelValue', productos.value);
emit('update:totals', calculos.value);
};
// Watch para actualizar cuando cambian los productos
watch(productos, () => {
actualizarProductos();
}, { deep: true });
// Watch para sincronizar con el v-model externo
watch(
() => props.modelValue,
(newVal) => {
if (JSON.stringify(newVal) !== JSON.stringify(productos.value)) {
productos.value = [...newVal];
}
},
{ deep: true }
);
/**
* Formatear moneda
*/
const formatCurrency = (value) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(value || 0);
};
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20">
<!-- ENCABEZADO -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-primary-dt">
Productos / Servicios
</h3>
<p class="text-xs text-gray-500 dark:text-primary-dt/60">
Listado de productos en la remisión
</p>
</div>
<button
@click="agregarProducto"
type="button"
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
<GoogleIcon name="add" class="text-lg" />
Agregar Producto
</button>
</div>
<!-- PRODUCTOS -->
<div v-if="productos.length > 0" class="space-y-3">
<div
v-for="(producto, index) in productos"
:key="index"
class="border-2 border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors dark:border-primary/20 dark:hover:border-blue-500"
>
<!-- Header -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="bg-blue-100 text-blue-700 font-bold px-3 py-1 rounded text-sm dark:bg-blue-900/30 dark:text-blue-300">
#{{ index + 1 }}
</span>
</div>
<button
@click="eliminarProducto(index)"
type="button"
class="text-red-500 hover:text-red-700 p-2 hover:bg-red-50 rounded-lg transition-colors dark:text-red-400 dark:hover:bg-red-900/20"
title="Eliminar producto"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
<!-- Grid de campos -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- CANTIDAD -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Cantidad <span class="text-red-500">*</span>
</label>
<input
v-model.number="producto.cantidad"
type="number"
min="0"
step="1"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
/>
</div>
<!-- UNIDAD -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Unidad <span class="text-red-500">*</span>
</label>
<select
v-model="producto.unidad"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
>
<option v-for="unidad in unidades" :key="unidad.codigo" :value="unidad.codigo">
{{ unidad.nombre }}
</option>
</select>
</div>
<!-- CÓDIGO -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Código
</label>
<input
v-model="producto.codigo"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
placeholder="SKU-001"
/>
</div>
<!-- PRECIO UNITARIO -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Precio Unit. <span class="text-red-500">*</span>
</label>
<input
v-model.number="producto.precioUnitario"
type="number"
min="0"
step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
placeholder="0.00"
/>
</div>
</div>
<!-- DESCRIPCIÓN -->
<div class="mt-4">
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Descripción <span class="text-red-500">*</span>
</label>
<textarea
v-model="producto.descripcion"
rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500 resize-y"
placeholder="Descripción detallada del producto..."
></textarea>
</div>
<!-- IMPORTE -->
<div class="mt-3 flex justify-end">
<div class="text-right">
<span class="text-xs text-gray-500 dark:text-primary-dt/70">Importe:</span>
<div class="text-lg font-bold text-blue-700 dark:text-blue-400">
{{ formatCurrency(calcularImporte(producto)) }}
</div>
</div>
</div>
</div>
</div>
<!-- Estado vacío -->
<div v-else class="text-center py-12 text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="inventory_2" class="text-5xl mb-3 opacity-50" />
<p class="text-sm font-semibold">No hay productos agregados</p>
<p class="text-xs">Haz clic en "Agregar Producto" para comenzar</p>
</div>
<!-- RESUMEN DE TOTALES -->
<div v-if="productos.length > 0" class="mt-6 flex justify-end">
<div class="bg-blue-50 rounded-lg p-4 min-w-[250px] dark:bg-blue-900/20">
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-sm font-semibold text-gray-700 dark:text-primary-dt">
Total:
</span>
<span class="text-xl font-bold text-blue-700 dark:text-blue-400">
{{ formatCurrency(calculos.total) }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

@ -2,31 +2,17 @@
import { ref } from 'vue'; import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'; import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({ const props = defineProps({
type: { type: { type: String, required: true },
type: String, icon: { type: String, required: true },
required: true, title: { type: String, required: true },
},
icon: String,
title: String,
description: String,
}); });
/** Eventos */
const emit = defineEmits(['dragstart']);
/** Referencias */
const isDragging = ref(false); const isDragging = ref(false);
/** Métodos */
const handleDragStart = (event) => { const handleDragStart = (event) => {
isDragging.value = true; isDragging.value = true;
event.dataTransfer.setData('text/plain', JSON.stringify({ event.dataTransfer.setData('text/plain', JSON.stringify({ type: props.type }));
type: props.type,
title: props.title
}));
emit('dragstart', props.type);
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
@ -39,26 +25,23 @@ const handleDragEnd = () => {
draggable="true" draggable="true"
@dragstart="handleDragStart" @dragstart="handleDragStart"
@dragend="handleDragEnd" @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="{ :class="{
'opacity-50 cursor-grabbing': isDragging, 'opacity-50 cursor-grabbing': isDragging,
'shadow-sm hover:shadow-md': !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 <GoogleIcon
:name="icon" :name="icon"
class="text-blue-600 dark:text-blue-400 text-lg" class="text-blue-600 text-sm sm:text-lg"
/> />
</div> </div>
<div class="flex-1 min-w-0"> <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 }} {{ title }}
</div> </div>
<div class="text-xs text-gray-500 dark:text-primary-dt/70 truncate">
{{ description }}
</div>
</div> </div>
</div> </div>
</template> </template>

View File

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

View File

@ -32,20 +32,6 @@ const pageSizes = [
height: 1587, height: 1587,
description: 'Doble de A4' 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', name: 'Tabloid',
label: 'Tabloide (279 x 432 mm)', label: 'Tabloide (279 x 432 mm)',
@ -65,6 +51,10 @@ const selectSize = (size) => {
emit('update:modelValue', size.name); emit('update:modelValue', size.name);
isOpen.value = false; isOpen.value = false;
}; };
const closeDropdown = () => {
isOpen.value = false;
};
</script> </script>
<template> <template>
@ -72,13 +62,13 @@ const selectSize = (size) => {
<!-- Selector principal --> <!-- Selector principal -->
<button <button
@click="isOpen = !isOpen" @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" /> <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">{{ selectedSize.name }}</span> <span class="text-gray-700 dark:text-primary-dt font-medium truncate">{{ selectedSize.name }}</span>
<GoogleIcon <GoogleIcon
name="expand_more" 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 }" :class="{ 'rotate-180': isOpen }"
/> />
</button> </button>
@ -86,53 +76,57 @@ const selectSize = (size) => {
<!-- Dropdown --> <!-- Dropdown -->
<div <div
v-if="isOpen" v-if="isOpen"
@click.away="isOpen = false" v-click-away="closeDropdown"
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" 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"> <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 Tamaños de página
</div> </div>
<div class="max-h-64 overflow-y-auto"> <div class="max-h-60 overflow-y-auto">
<button <button
v-for="size in pageSizes" v-for="size in pageSizes"
:key="size.name" :key="size.name"
@click="selectSize(size)" @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="{ :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="flex-shrink-0">
<div <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="{ :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 <div
class="bg-gray-200 dark:bg-primary/20 rounded-sm" class="bg-gray-300 dark:bg-primary/40 rounded-sm"
:style="{ :style="{
width: `${Math.min(20, (size.width / size.height) * 32)}px`, width: `${Math.min(16, (size.width / size.height) * 24)}px`,
height: `${Math.min(32, (size.height / size.width) * 20)}px` height: `${Math.min(24, (size.height / size.width) * 16)}px`
}" }"
:class="{ :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> </div>
</div> </div>
<!-- Información del tamaño -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-primary-dt">{{ size.label }}</div> <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">{{ size.description }}</div> <div class="text-xs text-gray-500 dark:text-primary-dt/70 truncate">
<div class="text-xs text-gray-400 dark:text-primary-dt/50 mt-1"> {{ size.description }}
{{ size.width }} x {{ size.height }} px
</div> </div>
</div> </div>
<!-- Indicador de selección -->
<div v-if="selectedSize.name === size.name" class="flex-shrink-0"> <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> </div>
</button> </button>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,11 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import useLoader from '@Stores/Loader';
import { hasPermission } from '@Plugins/RolePermission'; import { hasPermission } from '@Plugins/RolePermission';
import { hasToken } from '@Services/Api';
import { reloadApp } from '@Services/Page';
import { useRouter } from 'vue-router';
import useLoader from '@Stores/Loader';
import Layout from '@Holos/Layout/App.vue'; import Layout from '@Holos/Layout/App.vue';
import Link from '@Holos/Skeleton/Sidebar/Link.vue'; import Link from '@Holos/Skeleton/Sidebar/Link.vue';
@ -10,6 +14,7 @@ import DropDown from '@Holos/Skeleton/Sidebar/Drop.vue'
/** Definidores */ /** Definidores */
const loader = useLoader() const loader = useLoader()
const router = useRouter();
/** Propiedades */ /** Propiedades */
defineProps({ defineProps({
@ -19,6 +24,12 @@ defineProps({
/** Ciclos */ /** Ciclos */
onMounted(() => { onMounted(() => {
loader.boot() loader.boot()
if(!hasToken()) {
return router.push({ name: 'auth.index' })
} else {
reloadApp();
}
}) })
</script> </script>
@ -63,6 +74,13 @@ onMounted(() => {
/> />
</DropDown> </DropDown>
</Section> </Section>
<Section name="Vacaciones">
<Link
icon="grid_view"
name="Vacaciones"
to="admin.vacations.index"
/>
</Section>
<Section name="Capacitaciones"> <Section name="Capacitaciones">
<DropDown <DropDown
icon="grid_view" icon="grid_view"
@ -143,6 +161,12 @@ onMounted(() => {
name="Maquetador de Documentos" name="Maquetador de Documentos"
to="admin.maquetador.index" to="admin.maquetador.index"
/> />
<Link
v-if="hasPermission('activities.index')"
icon="event"
name="Plantillas"
to="admin.templates.index"
/>
</Section> </Section>
</template> </template>
<!-- Contenido --> <!-- Contenido -->

View File

@ -1,5 +1,9 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { hasToken } from '@Services/Api';
import { reloadApp } from '@Services/Page';
import { useRouter } from 'vue-router';
import useLoader from '@Stores/Loader'; import useLoader from '@Stores/Loader';
import Layout from '@Holos/Layout/App.vue'; import Layout from '@Holos/Layout/App.vue';
@ -8,6 +12,7 @@ import Section from '@Holos/Skeleton/Sidebar/Section.vue';
/** Definidores */ /** Definidores */
const loader = useLoader() const loader = useLoader()
const router = useRouter();
/** Propiedades */ /** Propiedades */
defineProps({ defineProps({
@ -17,6 +22,12 @@ defineProps({
/** Ciclos */ /** Ciclos */
onMounted(() => { onMounted(() => {
loader.boot() loader.boot()
if(!hasToken()) {
return router.push({ name: 'auth.index' })
} else {
reloadApp();
}
}) })
</script> </script>
@ -32,6 +43,11 @@ onMounted(() => {
name="Dashboard" name="Dashboard"
to="dashboard.index" to="dashboard.index"
/> />
<Link
icon="calendar_month"
name="Vacaciones"
to="vacations.index"
/>
<Link <Link
icon="person" icon="person"
name="Perfil" name="Perfil"

View File

@ -1,5 +1,9 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { hasToken } from '@Services/Api';
import { reloadApp } from '@Services/Page';
import { useRouter } from 'vue-router';
import useLoader from '@Stores/Loader'; import useLoader from '@Stores/Loader';
import Layout from '@Holos/Layout/App.vue'; import Layout from '@Holos/Layout/App.vue';
@ -8,6 +12,7 @@ import Section from '@Holos/Skeleton/Sidebar/Section.vue';
/** Definidores */ /** Definidores */
const loader = useLoader() const loader = useLoader()
const router = useRouter();
/** Propiedades */ /** Propiedades */
defineProps({ defineProps({
@ -17,6 +22,12 @@ defineProps({
/** Ciclos */ /** Ciclos */
onMounted(() => { onMounted(() => {
loader.boot() loader.boot()
if(!hasToken()) {
return router.push({ name: 'auth.index' })
} else {
reloadApp();
}
}) })
</script> </script>
@ -32,6 +43,11 @@ onMounted(() => {
name="Dashboard" name="Dashboard"
to="coordinator.dashboard.index" to="coordinator.dashboard.index"
/> />
<Link
icon="calendar_month"
name="Vacaciones"
to="vacations.coordinator.index"
/>
</Section> </Section>
<Section <Section
:name="$t('admin.title')" :name="$t('admin.title')"

View File

@ -30,11 +30,39 @@ const login = () => {
defineUser(res.user) defineUser(res.user)
defineCsrfToken(res.csrf) defineCsrfToken(res.csrf)
location.replace('/') // Redirección basada en roles
redirectBasedOnRole(res.userRoles)
} }
}); });
}; };
const redirectBasedOnRole = (userRoles) => {
// Si no tiene roles o el array está vacío, redirigir a "/"
if (!userRoles || userRoles.length === 0) {
router.push('/')
return
}
// Verificar si tiene el rol coordinador
const hasCoordinatorRole = userRoles.some(role => role.name === 'coordinator')
if (hasCoordinatorRole) {
router.push('/coordinator')
return
}
// Verificar si tiene rol admin o developer
const hasAdminOrDeveloperRole = userRoles.some(role =>
role.name === 'admin' || role.name === 'developer'
)
if (hasAdminOrDeveloperRole) {
router.push('/admin')
return
}
// Si no cumple ninguna condición, redirigir a "/"
router.push('/')
};
/** Ciclos */ /** Ciclos */
onMounted(() => { onMounted(() => {
if (hasToken()) { if (hasToken()) {

View File

@ -0,0 +1,16 @@
import { lang } from '@Lang/i18n';
// Ruta API
const apiTo = (name, params = {}) => route(`dashboard.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `dashboard.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`dashboard.${str}`)
export {
viewTo,
apiTo,
transl
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -0,0 +1,95 @@
export default{
templateId: 'temp-rem-001',
nombre: 'Remisión',
branding: {
logo: null,
primaryColor: '#2c50dd',
slogan: ' ',
},
campos:[
{
seccion: 'Datos de la Empresa',
campos: [
{
key: 'empresaNombre',
label: 'Nombre de la Empresa',
tipo: 'text',
required: true,
placeholder: 'Ej: GOLSYSTEMS',
},
{
key: 'empresaRFC',
label: 'RFC',
tipo: 'text',
required: true,
placeholder: 'Ej: ABC123456789',
},
{
key: 'empresaDireccion',
label: 'Dirección',
tipo: 'textarea',
required: true,
placeholder: 'Dirección completa',
},
],
},
{
seccion : 'Datos del Cliente',
campos: [
{
key: 'clienteNombre',
label: 'Nombre del Cliente',
tipo: 'text',
required: true,
placeholder: 'Ej: Juan Pérez',
},
{
key: 'clienteRFC',
label: 'RFC',
tipo: 'text',
required: false,
placeholder: 'Ej: ABC123456789',
},
{
key: 'clienteTelefono',
label: 'Teléfono',
tipo: 'text',
required: false,
placeholder: 'Ej: 555-123-4567',
},
{
key: 'clienteDomicilio',
label: 'Dirección',
tipo: 'textarea',
required: true,
placeholder: 'Dirección completa',
},
]
},
{
seccion: 'Detalles del Documento',
campos: [
{
key: 'serie',
label: 'Número de Serie',
tipo: 'text',
required: true,
},
{
key: 'folio',
label: 'Número de Folio',
tipo: 'text',
required: true,
},
{
key: 'fechaRemision',
label: 'Fecha',
tipo: 'date',
required: true,
},
]
}
]
}

View File

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

View File

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

View File

@ -0,0 +1,476 @@
<script setup>
import { ref, computed, watch, onMounted } from "vue";
import { usePDFExport } from "@Pages/Templates/Configs/usePDFExport";
import { useDocument } from "@/composables/useDocument";
import ConfigCotizacion from "@Pages/Templates/Configs/ConfigCotizacion.js";
import ConfigFacturacion from "@Pages/Templates/Configs/ConfigFacturacion.js";
import ConfigRemision from "@Pages/Templates/Configs/ConfigRemision.js";
import Document from "./DocumentTemplate.vue";
import ProductTable from "@Holos/DocumentSection/CotizacionTable.vue";
import FacturaTable from "@Holos/DocumentSection/FacturacionTable.vue";
import RemisionTable from "@Holos/DocumentSection/RemisionTable.vue";
import Input from "@Holos/Form/Input.vue";
import Textarea from "@Holos/Form/Textarea.vue";
import PrimaryButton from "@Holos/Button/Primary.vue";
import GoogleIcon from "@Shared/GoogleIcon.vue";
/** Props (opcional: para modo edición) */
const props = defineProps({
id: {
type: [String, Number],
default: null
},
type: {
type: String,
default: 'COTIZACION'
}
});
/** Composables */
const { exportToPDF, isExporting } = usePDFExport();
const {
documentId,
documentType,
formData,
branding,
productos,
totales,
isLoading,
isSaving,
error,
initializeForm,
loadDocument,
saveDocument,
uploadLogo,
generatePDF
} = useDocument({
documentId: props.id,
documentType: props.type
});
/** Estado Local */
const templateConfig = ref(ConfigCotizacion);
const showPreview = ref(true);
/** Computed */
const documentData = computed(() => {
return {
...formData.value,
template: {
documentType: branding.value.documentType,
primaryColor: branding.value.primaryColor,
secondaryColor: branding.value.secondaryColor,
logo: branding.value.logoPreview,
slogan: branding.value.slogan,
},
productos: productos.value,
...totales.value,
};
});
/** Métodos */
const handleLogoUpload = async (file) => {
if (!file) {
console.warn("No se seleccionó archivo");
return;
}
console.log("Archivo seleccionado:", file.name, file.type);
await uploadLogo(file);
};
const handleTotalsUpdate = (newTotals) => {
totales.value = { ...newTotals };
};
const handleExport = () => {
exportToPDF(
"template-preview",
`${branding.value.documentType}-${formData.value.folio || "documento"}.pdf`
);
};
const handleSave = async () => {
await saveDocument({
onSuccess: (data) => {
console.log('Documento guardado:', data);
},
onFail: (err) => {
console.error('Error al guardar:', err);
}
});
};
const handleGeneratePDF = async () => {
await generatePDF();
};
/** Watchers */
watch(
() => branding.value.documentType,
(newType) => {
if (newType === "FACTURA") {
templateConfig.value = ConfigFacturacion;
} else if (newType === "REMISION") {
templateConfig.value = ConfigRemision;
} else {
templateConfig.value = ConfigCotizacion;
}
initializeForm(templateConfig.value);
productos.value = [];
}
);
/** Ciclos de vida */
onMounted(async () => {
// Si hay ID, cargar documento existente
if (props.id) {
await loadDocument(props.id);
// Actualizar templateConfig según el tipo cargado
if (documentType.value === "FACTURA") {
templateConfig.value = ConfigFacturacion;
} else if (documentType.value === "REMISION") {
templateConfig.value = ConfigRemision;
} else {
templateConfig.value = ConfigCotizacion;
}
} else {
// Nuevo documento: inicializar formulario vacío
initializeForm(templateConfig.value);
}
});
</script>
<template>
<div class="p-6">
<!-- Loading overlay -->
<div
v-if="isLoading"
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"
>
<div class="bg-white dark:bg-primary-d rounded-lg p-6 flex items-center gap-3">
<GoogleIcon name="hourglass_empty" class="animate-spin text-2xl text-blue-600" />
<span class="text-gray-900 dark:text-primary-dt font-medium">
Cargando documento...
</span>
</div>
</div>
<!-- Error message -->
<div
v-if="error"
class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 dark:bg-red-900/20 dark:border-red-700"
>
<GoogleIcon name="error" class="text-red-600 dark:text-red-400 text-xl" />
<div class="flex-1">
<p class="text-red-800 dark:text-red-300 font-medium">Error</p>
<p class="text-red-600 dark:text-red-400 text-sm">{{ error }}</p>
</div>
<button
@click="error = null"
class="text-red-600 hover:text-red-800 dark:text-red-400"
>
<GoogleIcon name="close" />
</button>
</div>
<!-- Header -->
<div class="mb-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-primary-dt">
{{ templateConfig.nombre }}
</h2>
<p class="text-sm text-gray-600 dark:text-primary-dt/70">
Genera documento PDF
<span v-if="documentId" class="text-blue-600 dark:text-blue-400">
· ID: {{ documentId }}
</span>
<span v-if="isSaving" class="text-orange-600 dark:text-orange-400">
· Guardando...
</span>
</p>
</div>
<!-- Botones de acción (desktop) -->
<div class="hidden lg:flex items-center gap-3">
<button
@click="showPreview = !showPreview"
class="flex items-center gap-2 px-3 py-2 rounded-lg border-2"
:class="
showPreview
? 'bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-100 dark:bg-blue-900/30 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900/50'
: 'bg-gray-50 border-gray-300 text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700'
"
>
<GoogleIcon
:name="showPreview ? 'visibility_off' : 'visibility'"
class="text-base"
/>
<span class="text-sm font-medium">
{{ showPreview ? "Ocultar" : "Mostrar" }} Vista Previa
</span>
</button>
<!-- Botón Guardar -->
<PrimaryButton
@click="handleSave"
:disabled="isSaving"
class="px-4 py-2 bg-green-600 hover:bg-green-700"
>
<GoogleIcon name="save" class="mr-2 text-sm" />
{{ isSaving ? "Guardando..." : "Guardar" }}
</PrimaryButton>
<!-- Botón Exportar PDF (cliente) -->
<PrimaryButton
@click="handleExport"
:disabled="isExporting"
class="px-4 py-2"
>
<GoogleIcon name="picture_as_pdf" class="mr-2 text-sm" />
{{ isExporting ? "Generando..." : "Exportar PDF" }}
</PrimaryButton>
</div>
</div>
<!-- Selector de Tipo de Documento -->
<div class="min-w-[250px]">
<label
class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-1"
>
Tipo de Documento
</label>
<select
v-model="branding.documentType"
:disabled="!!documentId"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 font-medium dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="COTIZACION">Cotización</option>
<option value="FACTURA">Factura</option>
<option value="REMISION">Remisión</option>
</select>
</div>
</div>
<div
class="grid gap-5"
:class="showPreview ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'"
>
<!-- FORMULARIO -->
<div class="space-y-6">
<!-- SECCIÓN DE BRANDING -->
<div
class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border-2 border-blue-200 p-4 dark:from-blue-900/20 dark:to-indigo-900/20 dark:border-blue-700"
>
<div class="flex items-center gap-2 mb-3">
<GoogleIcon
name="palette"
class="text-blue-600 dark:text-blue-400"
/>
<h3
class="text-base font-semibold text-gray-900 dark:text-primary-dt"
>
Personalización del Documento
</h3>
</div>
<div class="space-y-3">
<!-- Slogan -->
<Input
v-model="branding.slogan"
title="Slogan de la Empresa"
type="text"
placeholder="Optimizando lasTIC's en las empresas"
/>
<!-- Logo de Imagen -->
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-2"
>
Logo de la Empresa
</label>
<!-- Preview del logo -->
<div
v-if="branding.logoPreview"
class="mb-3 flex items-center gap-3 p-3 bg-white rounded border dark:bg-primary-d dark:border-primary/20"
>
<img
:src="branding.logoPreview"
alt="Logo"
class="h-16 object-contain"
/>
<button
@click="
branding.logoPreview = null;
branding.logo = null;
branding.logoUrl = null;
"
type="button"
class="ml-auto text-red-500 hover:text-red-700 dark:text-red-400"
>
<GoogleIcon name="delete" class="text-lg" />
</button>
</div>
<!-- Input para subir logo -->
<input
type="file"
accept="image/png, image/jpeg, image/jpg"
@change="(e) => handleLogoUpload(e.target.files[0])"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-blue-900/30 dark:file:text-blue-300"
/>
</div>
<!-- Colores -->
<div class="grid grid-cols-2 gap-2">
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-1"
>
Color Primario
</label>
<div class="flex gap-2">
<input
v-model="branding.primaryColor"
type="color"
class="h-10 w-16 rounded border border-gray-300 cursor-pointer"
/>
<input
v-model="branding.primaryColor"
type="text"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
placeholder="#2563eb"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Secciones del formulario -->
<div
v-for="seccion in templateConfig.campos"
:key="seccion.seccion"
class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20"
>
<h3
class="text-base font-semibold text-gray-900 mb-3 dark:text-primary-dt"
>
{{ seccion.seccion }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<template v-for="campo in seccion.campos" :key="campo.key">
<Input
v-if="
['text', 'email', 'url', 'tel', 'number', 'date'].includes(
campo.tipo
)
"
v-model="formData[campo.key]"
:id="campo.key"
:title="campo.label"
:type="campo.tipo"
:required="campo.required"
:placeholder="campo.placeholder"
/>
<Textarea
v-else-if="campo.tipo === 'textarea'"
v-model="formData[campo.key]"
:id="campo.key"
:title="campo.label"
:required="campo.required"
:placeholder="campo.placeholder"
class="md:col-span-2"
/>
<div v-else-if="campo.tipo === 'select'" class="md:col-span-2">
<label
:for="campo.key"
class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-1"
>
{{ campo.label }}
<span v-if="campo.required" class="text-red-500">*</span>
</label>
<select
v-model="formData[campo.key]"
:id="campo.key"
:required="campo.required"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
>
<option value="">Seleccione una opción</option>
<option
v-for="opcion in campo.opciones"
:key="opcion.value"
:value="opcion.value"
>
{{ opcion.label }}
</option>
</select>
</div>
</template>
</div>
</div>
<!-- Tabla de Productos -->
<FacturaTable
v-if="branding.documentType === 'FACTURA'"
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
<RemisionTable
v-if="branding.documentType === 'REMISION'"
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
<ProductTable
v-else
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
</div>
<!-- VISTA PREVIA -->
<div v-if="showPreview" class="sticky top-6 h-fit">
<div class="mb-4">
<h3 class="font-semibold text-gray-900 dark:text-primary-dt">
Vista Previa
</h3>
</div>
<div
class="border rounded-lg overflow-auto bg-gray-50 dark:bg-gray-900 flex justify-center p-4"
style="max-height: 80vh"
>
<div
id="template-preview"
class="shadow-lg"
style="width: 210mm; height: 297mm; overflow: hidden"
>
<Document :documentData="documentData" />
</div>
</div>
</div>
<div v-else style="position: absolute; left: -9999px; top: 0">
<div
id="template-preview"
class="shadow-lg"
style="width: 210mm; height: 297mm; overflow: hidden"
>
<Document :documentData="documentData" />
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,181 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { apiTo, viewTo } from './Module'
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Form from './Form.vue';
/** Definidores */
const router = useRouter();
const form = useForm({
periods: [
{
start_date: '',
end_date: '',
number_of_days: 0,
}
],
comments: ''
});
const availableDays = ref(0);
const formRef = ref(null);
// Calcular días restantes
const remainingDays = computed(() => {
if (!formRef.value) return availableDays.value;
return availableDays.value - formRef.value.totalSelectedDays;
});
// Calcular total de días seleccionados
const totalSelectedDays = computed(() => {
if (!formRef.value) return 0;
return formRef.value.totalSelectedDays;
});
// Obtener períodos con datos
const periodsWithData = computed(() => {
return form.periods.filter(period => period.start_date && period.end_date && period.number_of_days > 0);
});
/** Métodos */
function submit() {
form.post(apiTo('store'), {
onSuccess: (res) => {
Notify.success(Lang('created'));
router.push(viewTo({ name: 'index' }));
}
});
}
/** Ciclos */
onMounted(() => {
api.get(route('resources.available-days'), {
onSuccess: (res) => {
availableDays.value = res.available_days;
}
});
});
</script>
<template>
<div>
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-4 mb-4">
<RouterLink :to="viewTo({ name: 'index' })">
<button class="flex items-center justify-center w-10 h-10 rounded-lg bg-slate-100 hover:bg-slate-200 with-transition dark:bg-slate-800 dark:hover:bg-slate-700">
<GoogleIcon name="arrow_back" class="text-slate-600 dark:text-slate-300" />
</button>
</RouterLink>
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Solicitar Vacaciones</h1>
<p class="text-slate-600 dark:text-slate-400">Completa el formulario para solicitar tus días de vacaciones.</p>
</div>
</div>
</div>
<!-- Layout de dos columnas -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Formulario - Columna izquierda (2/3) -->
<div class="lg:col-span-2">
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 dark:bg-slate-800 dark:border-slate-700">
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Detalles de la Solicitud</h2>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-6">Selecciona las fechas y proporciona información adicional.</p>
<Form
ref="formRef"
:form="form"
@submit="submit"
/>
</div>
</div>
<!-- Resumen - Columna derecha (1/3) -->
<div class="lg:col-span-1">
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 dark:bg-slate-800 dark:border-slate-700">
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">Resumen de Solicitud</h3>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-6">Revisa los detalles antes de enviar.</p>
<!-- Días disponibles -->
<div class="text-center mb-6 p-4 bg-slate-50 rounded-lg dark:bg-slate-700/50">
<div class="text-3xl font-bold text-slate-900 dark:text-slate-100">{{ availableDays }}</div>
<div class="text-sm text-slate-600 dark:text-slate-400">Días Disponibles</div>
</div>
<!-- Detalles de períodos -->
<div v-if="periodsWithData.length > 0" class="space-y-4 mb-6">
<div v-for="(period, index) in periodsWithData" :key="index" class="space-y-2">
<h4 class="font-medium text-slate-900 dark:text-slate-100">
Período {{ index + 1 }}:
</h4>
<div class="space-y-1 text-sm text-slate-600 dark:text-slate-400">
<div>Inicio: {{ period.start_date }}</div>
<div>Fin: {{ period.end_date }}</div>
<div class="flex items-center gap-2">
<span>Días en período {{ index + 1 }}:</span>
<span class="px-2 py-1 bg-slate-100 text-slate-700 rounded-full text-xs font-medium dark:bg-slate-600 dark:text-slate-300">
{{ period.number_of_days }} día{{ period.number_of_days > 1 ? 's' : '' }}
</span>
</div>
</div>
</div>
</div>
<!-- Resumen de días -->
<div v-if="totalSelectedDays > 0" class="space-y-3 mb-6">
<div class="flex items-center justify-between">
<span class="text-sm text-slate-600 dark:text-slate-400">Total de días:</span>
<span class="px-2 py-1 bg-slate-100 text-slate-700 rounded-full text-xs font-medium dark:bg-slate-600 dark:text-slate-300">
{{ totalSelectedDays }} día{{ totalSelectedDays > 1 ? 's' : '' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-slate-600 dark:text-slate-400">Días restantes:</span>
<span class="px-2 py-1 rounded-full text-xs font-medium"
:class="remainingDays < 0 ? 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'">
{{ remainingDays }} día{{ remainingDays !== 1 ? 's' : '' }}
</span>
</div>
</div>
<!-- Advertencia si se excede el límite -->
<div v-if="remainingDays < 0" class="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div class="flex items-center gap-2">
<GoogleIcon name="warning" class="w-4 h-4 text-red-600 dark:text-red-400" />
<span class="text-sm text-red-600 dark:text-red-400 font-medium">
Excedes tus días disponibles por {{ Math.abs(remainingDays) }} día{{ Math.abs(remainingDays) > 1 ? 's' : '' }}
</span>
</div>
</div>
<!-- Recordatorios -->
<div class="space-y-3">
<h4 class="font-medium text-slate-900 dark:text-slate-100">Recordatorios:</h4>
<div class="space-y-2 text-sm text-slate-600 dark:text-slate-400">
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Las solicitudes deben hacerse con 15 días de anticipación</span>
</div>
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Tu coordinador revisará y aprobará la solicitud</span>
</div>
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Recibirás una notificación con la decisión</span>
</div>
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Los días se descontarán una vez aprobados</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,236 @@
<script setup>
import { ref, watch, computed } from 'vue';
import VueDatePicker from '@vuepic/vue-datepicker';
import '@vuepic/vue-datepicker/dist/main.css';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
import Textarea from '@Holos/Form/Textarea.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits([
'submit'
])
/** Propiedades */
const props = defineProps({
form: Object
})
/** Métodos */
function submit() {
emit('submit')
}
function addPeriod() {
props.form.periods.push({
start_date: '',
end_date: '',
number_of_days: 0,
});
}
function removePeriod(index) {
if (props.form.periods.length > 1) {
props.form.periods.splice(index, 1);
}
}
function calculateDays(startDate, endDate) {
if (!startDate || !endDate) return 0;
const start = new Date(startDate);
const end = new Date(endDate);
// Validar que la fecha de inicio no sea posterior a la de fin
if (start > end) return 0;
// Calcular la diferencia en días (incluyendo ambos días)
const timeDiff = end.getTime() - start.getTime();
const daysDiff = Math.ceil(timeDiff / (1000 * 3600 * 24)) + 1;
return daysDiff;
}
function updatePeriodDays(index) {
const period = props.form.periods[index];
if (period.start_date && period.end_date) {
period.number_of_days = calculateDays(period.start_date, period.end_date);
} else {
period.number_of_days = 0;
}
}
function getMinDate() {
const today = new Date();
let daysToAdd = 15;
let currentDate = new Date(today);
while (daysToAdd > 0) {
currentDate.setDate(currentDate.getDate() + 1);
// Si no es domingo (0), contar el día
if (currentDate.getDay() !== 0) {
daysToAdd--;
}
}
return currentDate.toISOString().split('T')[0];
}
// Función para desactivar domingos
function isDisabled(date) {
return date.getDay() === 0; // 0 = domingo
}
// Función para formatear fecha a Y-m-d
function formatDate(date) {
if (!date) return '';
// Si la fecha ya es un string en formato correcto, devolverla
if (typeof date === 'string' && date.match(/^\d{4}-\d{2}-\d{2}$/)) {
return date;
}
let d;
if (date instanceof Date) {
d = date;
} else {
d = new Date(date);
}
// Verificar que la fecha es válida
if (isNaN(d.getTime())) {
console.warn('Fecha inválida recibida:', date);
return '';
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Watcher para detectar cambios en las fechas
watch(() => props.form.periods, (newPeriods) => {
newPeriods.forEach((period, index) => {
if (period.start_date && period.end_date) {
updatePeriodDays(index);
}
});
}, { deep: true });
// Calcular total de días seleccionados
const totalSelectedDays = computed(() => {
return props.form.periods.reduce((total, period) => {
return total + (period.number_of_days || 0);
}, 0);
});
// Exponer el total para el componente padre
defineExpose({
totalSelectedDays
});
</script>
<template>
<form @submit.prevent="submit" class="space-y-6">
<!-- Períodos de vacaciones -->
<div class="space-y-4">
<div v-for="(period, index) in form.periods" :key="index" class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-slate-700 dark:text-slate-300">
Período {{ index + 1 }}
</h4>
<button
v-if="form.periods.length > 1"
type="button"
@click="removePeriod(index)"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-md with-transition dark:text-red-400 dark:hover:bg-red-500/10"
>
<GoogleIcon name="delete" class="w-4 h-4" />
Eliminar
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="relative">
<VueDatePicker
v-model="period.start_date"
class="w-full"
placeholder="Fecha de inicio"
format="dd/MM/yyyy"
locale="es"
:min-date="getMinDate()"
:disabled-dates="isDisabled"
:enable-time-picker="false"
:auto-apply="true"
:close-on-auto-apply="true"
@update:model-value="(date) => {
console.log('Fecha seleccionada (inicio):', date);
period.start_date = formatDate(date);
console.log('Fecha formateada (inicio):', period.start_date);
updatePeriodDays(index);
}"
/>
</div>
</div>
<div>
<div class="relative">
<VueDatePicker
v-model="period.end_date"
class="w-full"
placeholder="Fecha de fin"
format="dd/MM/yyyy"
locale="es"
:min-date="getMinDate()"
:disabled-dates="isDisabled"
:enable-time-picker="false"
:auto-apply="true"
:close-on-auto-apply="true"
@update:model-value="(date) => {
console.log('Fecha seleccionada (fin):', date);
period.end_date = formatDate(date);
console.log('Fecha formateada (fin):', period.end_date);
updatePeriodDays(index);
}"
/>
</div>
</div>
</div>
</div>
<!-- Botón añadir período -->
<button
type="button"
@click="addPeriod"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg with-transition dark:text-blue-400 dark:hover:bg-blue-500/10"
>
<GoogleIcon name="add" />
Añadir período
</button>
</div>
<!-- Comentarios -->
<div>
<Textarea
v-model="form.comments"
id="comments"
rows="4"
class="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100"
placeholder="Agrega cualquier información adicional sobre tu solicitud..."
:onError="form.errors.comments"
/>
</div>
<!-- Botón de envío -->
<div class="col-span-1 md:col-span-2 flex justify-center">
<PrimaryButton
v-text="$t('request')"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
/>
</div>
</form>
</template>

View File

@ -0,0 +1,129 @@
<script setup>
import { onMounted, ref } from 'vue';
import { api } from '@Services/Api';
import { getDate } from '@Controllers/DateController';
import { apiTo } from './Module'
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const vacationRequests = ref([]);
/** Metodos */
const generateConstance = (vacationId) => {
api.get(route('vacation-requests-public.generate-route', { vacation: vacationId }), {
onSuccess: (res) => {
Notify.success('Constancia generada correctamente');
openNewTab(res.route);
}
});
};
const getStatusClasses = (status) => {
const statusMap = {
'Pendiente': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
'Aprobado': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
'Rechazado': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
'Agendado': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
'Cancelado': 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
'Concluido': 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300'
};
return statusMap[status] || 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300';
};
const getStatusIcon = (status) => {
const iconMap = {
'Pendiente': { name: 'schedule', classes: 'text-amber-600 dark:text-amber-400' },
'Aprobado': { name: 'check_circle', classes: 'text-blue-600 dark:text-blue-400' },
'Rechazado': { name: 'cancel', classes: 'text-orange-600 dark:text-orange-400' },
'Agendado': { name: 'event', classes: 'text-green-600 dark:text-green-400' },
'Cancelado': { name: 'block', classes: 'text-red-600 dark:text-red-400' },
'Concluido': { name: 'task_alt', classes: 'text-cyan-600 dark:text-cyan-400' }
};
return iconMap[status] || { name: 'help', classes: 'text-gray-600 dark:text-gray-400' };
};
const getStatusIconContainer = (status) => {
const containerMap = {
'Pendiente': 'bg-amber-100 dark:bg-amber-900/30',
'Aprobado': 'bg-blue-100 dark:bg-blue-900/30',
'Rechazado': 'bg-orange-100 dark:bg-orange-900/30',
'Agendado': 'bg-green-100 dark:bg-green-900/30',
'Cancelado': 'bg-red-100 dark:bg-red-900/30',
'Concluido': 'bg-cyan-100 dark:bg-cyan-900/30'
};
return containerMap[status] || 'bg-gray-100 dark:bg-gray-900/30';
};
const openNewTab = (url) => {
window.open(url, '_blank');
}
/** Ciclos */
onMounted(() => {
api.get(apiTo('index'), {
onSuccess: (r) => {
vacationRequests.value = r.vacation_requests.data;
}
});
});
</script>
<template>
<div>
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">
Solicitudes
</h1>
<p class="text-slate-600 dark:text-slate-400">Gestión completa de todas las solicitudes de vacaciones del sistema.</p>
</div>
</div>
<!-- Lista de solicitudes -->
<div class="space-y-4">
<div v-for="vacation in vacationRequests" class="bg-white rounded-xl border border-slate-200 p-6 shadow-sm dark:bg-slate-800 dark:border-slate-700">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div :class="`flex items-center justify-center w-10 h-10 rounded-lg ${getStatusIconContainer(vacation.status)}`">
<GoogleIcon :name="getStatusIcon(vacation.status).name" :class="getStatusIcon(vacation.status).classes" />
</div>
<div>
<h3 v-if="vacation.vacation_periods.length > 1" class="font-semibold text-slate-900 dark:text-slate-100">{{ vacation.vacation_periods.length }} periodos</h3>
<h3 v-else class="font-semibold text-slate-900 dark:text-slate-100">{{ getDate(vacation.vacation_periods[0].start_date) }} - {{ getDate(vacation.vacation_periods[0].end_date) }}</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">{{ vacation.total_days }} días</p>
</div>
</div>
<span :class="`px-3 py-1 rounded-full text-sm font-medium ${getStatusClasses(vacation.status)}`">
{{ vacation.status }}
</span>
</div>
<div v-for="(period, index) in vacation.vacation_periods" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Período {{ index + 1 }}:</p>
<p class="text-sm text-slate-600 dark:text-slate-400">{{ getDate(period.start_date) }} - {{ getDate(period.end_date) }} ({{ period.number_of_days }} días)</p>
</div>
</div>
<div class="flex justify-end gap-3">
<button @click="generateConstance(vacation.id)" type="button" class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 hover:bg-slate-100 rounded-lg with-transition dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-700">
<GoogleIcon name="clinical_notes" />
Generar Constancia de disfrute de vacaciones
</button>
</div>
</div>
<!-- Estado vacío cuando no hay solicitudes -->
<div v-if="vacationRequests.length == 0" class="text-center py-12">
<GoogleIcon name="event_busy" class="text-slate-300 text-6xl mb-4 dark:text-slate-600" />
<h3 class="text-lg font-medium text-slate-900 dark:text-slate-100 mb-2">No hay solicitudes</h3>
<p class="text-slate-600 dark:text-slate-400 mb-6">No se han encontrado solicitudes de vacaciones en el sistema.</p>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { lang } from '@Lang/i18n';
// Ruta API
const apiTo = (name, params = {}) => route(`vacation-requests.admin.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `vacations.admin.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`vacations.${str}`)
export {
viewTo,
apiTo,
transl
}

View File

@ -0,0 +1,181 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { apiTo, viewTo } from './Module'
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Form from './Form.vue';
/** Definidores */
const router = useRouter();
const form = useForm({
periods: [
{
start_date: '',
end_date: '',
number_of_days: 0,
}
],
comments: ''
});
const availableDays = ref(0);
const formRef = ref(null);
// Calcular días restantes
const remainingDays = computed(() => {
if (!formRef.value) return availableDays.value;
return availableDays.value - formRef.value.totalSelectedDays;
});
// Calcular total de días seleccionados
const totalSelectedDays = computed(() => {
if (!formRef.value) return 0;
return formRef.value.totalSelectedDays;
});
// Obtener períodos con datos
const periodsWithData = computed(() => {
return form.periods.filter(period => period.start_date && period.end_date && period.number_of_days > 0);
});
/** Métodos */
function submit() {
form.post(apiTo('store'), {
onSuccess: (res) => {
Notify.success(Lang('created'));
router.push(viewTo({ name: 'index' }));
}
});
}
/** Ciclos */
onMounted(() => {
api.get(route('resources.available-days'), {
onSuccess: (res) => {
availableDays.value = res.available_days;
}
});
});
</script>
<template>
<div>
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-4 mb-4">
<RouterLink :to="viewTo({ name: 'index' })">
<button class="flex items-center justify-center w-10 h-10 rounded-lg bg-slate-100 hover:bg-slate-200 with-transition dark:bg-slate-800 dark:hover:bg-slate-700">
<GoogleIcon name="arrow_back" class="text-slate-600 dark:text-slate-300" />
</button>
</RouterLink>
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Solicitar Vacaciones</h1>
<p class="text-slate-600 dark:text-slate-400">Completa el formulario para solicitar tus días de vacaciones.</p>
</div>
</div>
</div>
<!-- Layout de dos columnas -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Formulario - Columna izquierda (2/3) -->
<div class="lg:col-span-2">
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 dark:bg-slate-800 dark:border-slate-700">
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Detalles de la Solicitud</h2>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-6">Selecciona las fechas y proporciona información adicional.</p>
<Form
ref="formRef"
:form="form"
@submit="submit"
/>
</div>
</div>
<!-- Resumen - Columna derecha (1/3) -->
<div class="lg:col-span-1">
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 dark:bg-slate-800 dark:border-slate-700">
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">Resumen de Solicitud</h3>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-6">Revisa los detalles antes de enviar.</p>
<!-- Días disponibles -->
<div class="text-center mb-6 p-4 bg-slate-50 rounded-lg dark:bg-slate-700/50">
<div class="text-3xl font-bold text-slate-900 dark:text-slate-100">{{ availableDays }}</div>
<div class="text-sm text-slate-600 dark:text-slate-400">Días Disponibles</div>
</div>
<!-- Detalles de períodos -->
<div v-if="periodsWithData.length > 0" class="space-y-4 mb-6">
<div v-for="(period, index) in periodsWithData" :key="index" class="space-y-2">
<h4 class="font-medium text-slate-900 dark:text-slate-100">
Período {{ index + 1 }}:
</h4>
<div class="space-y-1 text-sm text-slate-600 dark:text-slate-400">
<div>Inicio: {{ period.start_date }}</div>
<div>Fin: {{ period.end_date }}</div>
<div class="flex items-center gap-2">
<span>Días en período {{ index + 1 }}:</span>
<span class="px-2 py-1 bg-slate-100 text-slate-700 rounded-full text-xs font-medium dark:bg-slate-600 dark:text-slate-300">
{{ period.number_of_days }} día{{ period.number_of_days > 1 ? 's' : '' }}
</span>
</div>
</div>
</div>
</div>
<!-- Resumen de días -->
<div v-if="totalSelectedDays > 0" class="space-y-3 mb-6">
<div class="flex items-center justify-between">
<span class="text-sm text-slate-600 dark:text-slate-400">Total de días:</span>
<span class="px-2 py-1 bg-slate-100 text-slate-700 rounded-full text-xs font-medium dark:bg-slate-600 dark:text-slate-300">
{{ totalSelectedDays }} día{{ totalSelectedDays > 1 ? 's' : '' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-slate-600 dark:text-slate-400">Días restantes:</span>
<span class="px-2 py-1 rounded-full text-xs font-medium"
:class="remainingDays < 0 ? 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'">
{{ remainingDays }} día{{ remainingDays !== 1 ? 's' : '' }}
</span>
</div>
</div>
<!-- Advertencia si se excede el límite -->
<div v-if="remainingDays < 0" class="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div class="flex items-center gap-2">
<GoogleIcon name="warning" class="w-4 h-4 text-red-600 dark:text-red-400" />
<span class="text-sm text-red-600 dark:text-red-400 font-medium">
Excedes tus días disponibles por {{ Math.abs(remainingDays) }} día{{ Math.abs(remainingDays) > 1 ? 's' : '' }}
</span>
</div>
</div>
<!-- Recordatorios -->
<div class="space-y-3">
<h4 class="font-medium text-slate-900 dark:text-slate-100">Recordatorios:</h4>
<div class="space-y-2 text-sm text-slate-600 dark:text-slate-400">
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Las solicitudes deben hacerse con 15 días de anticipación</span>
</div>
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Tu coordinador revisará y aprobará la solicitud</span>
</div>
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Recibirás una notificación con la decisión</span>
</div>
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Los días se descontarán una vez aprobados</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,236 @@
<script setup>
import { ref, watch, computed } from 'vue';
import VueDatePicker from '@vuepic/vue-datepicker';
import '@vuepic/vue-datepicker/dist/main.css';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
import Textarea from '@Holos/Form/Textarea.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits([
'submit'
])
/** Propiedades */
const props = defineProps({
form: Object
})
/** Métodos */
function submit() {
emit('submit')
}
function addPeriod() {
props.form.periods.push({
start_date: '',
end_date: '',
number_of_days: 0,
});
}
function removePeriod(index) {
if (props.form.periods.length > 1) {
props.form.periods.splice(index, 1);
}
}
function calculateDays(startDate, endDate) {
if (!startDate || !endDate) return 0;
const start = new Date(startDate);
const end = new Date(endDate);
// Validar que la fecha de inicio no sea posterior a la de fin
if (start > end) return 0;
// Calcular la diferencia en días (incluyendo ambos días)
const timeDiff = end.getTime() - start.getTime();
const daysDiff = Math.ceil(timeDiff / (1000 * 3600 * 24)) + 1;
return daysDiff;
}
function updatePeriodDays(index) {
const period = props.form.periods[index];
if (period.start_date && period.end_date) {
period.number_of_days = calculateDays(period.start_date, period.end_date);
} else {
period.number_of_days = 0;
}
}
function getMinDate() {
const today = new Date();
let daysToAdd = 15;
let currentDate = new Date(today);
while (daysToAdd > 0) {
currentDate.setDate(currentDate.getDate() + 1);
// Si no es domingo (0), contar el día
if (currentDate.getDay() !== 0) {
daysToAdd--;
}
}
return currentDate.toISOString().split('T')[0];
}
// Función para desactivar domingos
function isDisabled(date) {
return date.getDay() === 0; // 0 = domingo
}
// Función para formatear fecha a Y-m-d
function formatDate(date) {
if (!date) return '';
// Si la fecha ya es un string en formato correcto, devolverla
if (typeof date === 'string' && date.match(/^\d{4}-\d{2}-\d{2}$/)) {
return date;
}
let d;
if (date instanceof Date) {
d = date;
} else {
d = new Date(date);
}
// Verificar que la fecha es válida
if (isNaN(d.getTime())) {
console.warn('Fecha inválida recibida:', date);
return '';
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Watcher para detectar cambios en las fechas
watch(() => props.form.periods, (newPeriods) => {
newPeriods.forEach((period, index) => {
if (period.start_date && period.end_date) {
updatePeriodDays(index);
}
});
}, { deep: true });
// Calcular total de días seleccionados
const totalSelectedDays = computed(() => {
return props.form.periods.reduce((total, period) => {
return total + (period.number_of_days || 0);
}, 0);
});
// Exponer el total para el componente padre
defineExpose({
totalSelectedDays
});
</script>
<template>
<form @submit.prevent="submit" class="space-y-6">
<!-- Períodos de vacaciones -->
<div class="space-y-4">
<div v-for="(period, index) in form.periods" :key="index" class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-slate-700 dark:text-slate-300">
Período {{ index + 1 }}
</h4>
<button
v-if="form.periods.length > 1"
type="button"
@click="removePeriod(index)"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-md with-transition dark:text-red-400 dark:hover:bg-red-500/10"
>
<GoogleIcon name="delete" class="w-4 h-4" />
Eliminar
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="relative">
<VueDatePicker
v-model="period.start_date"
class="w-full"
placeholder="Fecha de inicio"
format="dd/MM/yyyy"
locale="es"
:min-date="getMinDate()"
:disabled-dates="isDisabled"
:enable-time-picker="false"
:auto-apply="true"
:close-on-auto-apply="true"
@update:model-value="(date) => {
console.log('Fecha seleccionada (inicio):', date);
period.start_date = formatDate(date);
console.log('Fecha formateada (inicio):', period.start_date);
updatePeriodDays(index);
}"
/>
</div>
</div>
<div>
<div class="relative">
<VueDatePicker
v-model="period.end_date"
class="w-full"
placeholder="Fecha de fin"
format="dd/MM/yyyy"
locale="es"
:min-date="getMinDate()"
:disabled-dates="isDisabled"
:enable-time-picker="false"
:auto-apply="true"
:close-on-auto-apply="true"
@update:model-value="(date) => {
console.log('Fecha seleccionada (fin):', date);
period.end_date = formatDate(date);
console.log('Fecha formateada (fin):', period.end_date);
updatePeriodDays(index);
}"
/>
</div>
</div>
</div>
</div>
<!-- Botón añadir período -->
<button
type="button"
@click="addPeriod"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg with-transition dark:text-blue-400 dark:hover:bg-blue-500/10"
>
<GoogleIcon name="add" />
Añadir período
</button>
</div>
<!-- Comentarios -->
<div>
<Textarea
v-model="form.comments"
id="comments"
rows="4"
class="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100"
placeholder="Agrega cualquier información adicional sobre tu solicitud..."
:onError="form.errors.comments"
/>
</div>
<!-- Botón de envío -->
<div class="col-span-1 md:col-span-2 flex justify-center">
<PrimaryButton
v-text="$t('request')"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
/>
</div>
</form>
</template>

View File

@ -0,0 +1,295 @@
<script setup>
import { onMounted, ref } from 'vue';
import { api } from '@Services/Api';
import { apiTo } from './Module';
import Calendar from '@Components/Holos/Calendar.vue';
/** Propiedades */
const applicationsForCoordinator = ref([]);
const conflictDays = ref([]);
const calendarRef = ref(null);
/** Metodos */
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
// Función para calcular el total de días
const calculateTotalDays = (vacationPeriods) => {
return vacationPeriods.reduce((total, period) => total + period.number_of_days, 0);
};
// Función para obtener el color del estado
const getStatusColor = (status) => {
switch (status) {
case 'Pendiente':
return 'bg-yellow-100 text-yellow-800';
case 'Aprobada':
return 'bg-green-100 text-green-800';
case 'Rechazada':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
// Método para obtener los días de conflicto del mes
const fetchConflictDays = (month) => {
// Limpiar inmediatamente los días de conflicto para evitar el parpadeo visual
conflictDays.value = [];
api.get(apiTo('calendar-month', { month: month }), {
onSuccess: (r) => {
// El backend puede retornar un array vacío o un array con los días de conflicto
conflictDays.value = r.conflict_days || [];
},
});
};
const approvePeriod = (periodId) => {
api.put(apiTo('approve-period', { period: periodId }), {
onSuccess: (r) => {
Notify.success('Periodo aprobado correctamente');
// recargar la página
},
});
};
const approveAll = (applicationId) => {
api.put(apiTo('approve-request', { vacationRequest: applicationId }), {
onSuccess: (r) => {
Notify.success('Solicitud aprobada correctamente');
// recargar la página
},
});
};
/** Ciclos */
onMounted(() => {
// Cargar los días de conflicto del mes actual al montar el componente
const initialMonth = new Date().getMonth() + 1;
fetchConflictDays(initialMonth);
// Cargar las solicitudes para coordinador
api.get(apiTo('index'), {
onSuccess: (r) => {
applicationsForCoordinator.value = r.applications_for_coordinator;
}
});
});
</script>
<template>
<div>
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4 sm:my-8 gap-3 sm:gap-0">
<div>
<h1 class="text-xl sm:text-2xl font-bold text-slate-900 dark:text-slate-100">Gestión de Solicitudes</h1>
<p class="text-sm sm:text-base text-slate-600 dark:text-slate-400">Revisa y gestiona las solicitudes de vacaciones de tu equipo</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4 lg:gap-6">
<div class="order-2 lg:order-1 lg:col-span-8">
<!-- Lista de solicitudes -->
<div v-for="application in applicationsForCoordinator" :key="application.id" class="bg-white rounded-lg shadow-md p-4 sm:p-6 mb-4 sm:mb-6">
<!-- Información del empleado -->
<div class="flex flex-col sm:flex-row sm:items-center mb-4 sm:mb-6 gap-3 sm:gap-0">
<div class="flex items-center flex-1">
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-gray-200 rounded-full flex items-center justify-center mr-3 sm:mr-4 overflow-hidden flex-shrink-0">
<img v-if="application.user.profile_photo_url"
:src="application.user.profile_photo_url"
:alt="application.user.full_name"
class="w-full h-full object-cover">
<svg v-else class="w-5 h-5 sm:w-6 sm:h-6 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clip-rule="evenodd" />
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-1">
<h2 class="text-base sm:text-lg font-semibold text-gray-900 truncate">{{ application.user.full_name }}</h2>
<span :class="`px-2 py-1 text-xs rounded-full font-medium ${getStatusColor(application.status)} flex-shrink-0`">
{{ application.status }}
</span>
</div>
<p class="text-gray-500 text-sm">{{ application.user.department.name }}</p>
<p class="text-gray-400 text-xs">Días disponibles: {{ application.user.vacation_days_available }}</p>
</div>
</div>
<div class="text-left sm:text-right text-sm text-gray-500 flex-shrink-0">
<p>Solicitado: {{ formatDate(application.created_at) }}</p>
</div>
</div>
<!-- Conflicto -->
<div v-if="application.have_conflict && application.status == 'Pendiente'" class="mb-4 sm:mb-6">
<div class="bg-red-50 border border-red-200 rounded-lg p-3 sm:p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="w-4 h-4 sm:w-5 sm:h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-2 sm:ml-3 flex-1">
<h3 class="text-sm font-medium text-red-800">
Conflicto detectado
</h3>
<div class="mt-2 text-sm text-red-700">
<p class="mb-3">Las siguientes fechas presentan conflictos con otras solicitudes:</p>
<div class="space-y-2 sm:space-y-3">
<div v-for="conflict in application.have_conflict" :key="conflict.request_id"
class="bg-white border border-red-200 rounded-md p-2 sm:p-3">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-2 gap-1 sm:gap-0">
<span class="font-medium text-red-800">{{ conflict.user.name }}</span>
</div>
<div class="text-xs text-red-600 space-y-2">
<div class="flex flex-col sm:flex-row sm:justify-between gap-1 sm:gap-2">
<span class="font-medium">Período aprobado:</span>
<span class="break-all sm:break-normal">{{ formatDate(conflict.conflicting_period.start_date) }} - {{ formatDate(conflict.conflicting_period.end_date) }} ({{ conflict.conflicting_period.number_of_days }} días)</span>
</div>
<div class="flex flex-col sm:flex-row sm:justify-between gap-1 sm:gap-2">
<span class="font-medium">Período solicitado:</span>
<span class="break-all sm:break-normal">{{ formatDate(conflict.target_period.start_date) }} - {{ formatDate(conflict.target_period.end_date) }} ({{ conflict.target_period.number_of_days }} días)</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Períodos solicitados -->
<div class="mb-4 sm:mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3">Períodos solicitados:</h3>
<div class="space-y-2">
<div v-for="period in application.vacation_periods" :key="period.id"
class="flex flex-col sm:flex-row sm:items-center p-3 bg-gray-50 rounded-lg gap-3 sm:gap-0">
<div class="flex items-center flex-1">
<div class="w-6 h-6 sm:w-8 sm:h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2 sm:mr-3 flex-shrink-0">
<svg class="w-3 h-3 sm:w-4 sm:h-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
clip-rule="evenodd" />
</svg>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:flex-1 gap-2">
<span class="text-gray-700 text-sm">
{{ formatDate(period.start_date) }} - {{ formatDate(period.end_date) }}
</span>
<span class="px-2 py-1 bg-blue-100 text-blue-600 text-xs rounded-full font-medium self-start sm:self-auto">
{{ period.number_of_days }} {{ period.number_of_days === 1 ? 'día' : 'días' }}
</span>
</div>
</div>
<!-- Botones de acción para períodos individuales -->
<div v-if="application.status === 'Pendiente'" class="flex gap-2 justify-start sm:justify-end">
<button
class="flex items-center justify-center gap-1 bg-green-500 hover:bg-green-600 text-white font-medium py-1.5 px-3 rounded text-xs transition-colors"
@click="application.vacation_periods.length > 1 ? approvePeriod(period.id) : approveAll(application.id)"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
Aprobar
</button>
<button
class="flex items-center justify-center gap-1 bg-red-500 hover:bg-red-600 text-white font-medium py-1.5 px-3 rounded text-xs transition-colors">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
Rechazar
</button>
</div>
</div>
</div>
<!-- Total -->
<div class="mt-3 text-right">
<span class="text-sm font-medium text-gray-700">
Total: {{ calculateTotalDays(application.vacation_periods) }} días
</span>
</div>
</div>
<!-- Comentarios -->
<div v-if="application.comments" class="mb-4 sm:mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-2">Comentarios:</h3>
<div class="p-3 bg-gray-50 rounded-lg">
<span class="text-gray-700 text-sm">{{ application.comments }}</span>
</div>
</div>
<!-- Motivo de rechazo -->
<div v-if="application.rejection_reason" class="mb-4 sm:mb-6">
<h3 class="text-sm font-medium text-red-700 mb-2">Motivo de rechazo:</h3>
<div class="p-3 bg-red-50 rounded-lg border border-red-200">
<span class="text-red-700 text-sm">{{ application.rejection_reason }}</span>
</div>
</div>
<!-- Botones de acción -->
<div v-if="application.status === 'Pendiente' && application.vacation_periods.length > 1" class="flex flex-col sm:flex-row gap-2 sm:gap-3">
<button
class="flex items-center justify-center gap-2 bg-green-500 hover:bg-green-600 text-white font-medium py-2.5 sm:py-2 px-4 rounded-lg transition-colors"
@click="approveAll(application.id)"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
Aprobar todo
</button>
<button
class="flex items-center justify-center gap-2 bg-red-500 hover:bg-red-600 text-white font-medium py-2.5 sm:py-2 px-4 rounded-lg transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
Rechazar todo
</button>
</div>
</div>
<!-- Mensaje cuando no hay solicitudes -->
<div v-if="!applicationsForCoordinator || applicationsForCoordinator.length === 0"
class="text-center py-8 sm:py-12 px-4">
<div class="w-12 h-12 sm:w-16 sm:h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<h3 class="text-base sm:text-lg font-medium text-gray-900 mb-2">No hay solicitudes pendientes</h3>
<p class="text-sm sm:text-base text-gray-500">Tu equipo no tiene solicitudes de vacaciones que requieran revisión.</p>
</div>
</div>
<div class="order-1 lg:order-2 lg:col-span-4">
<Calendar
ref="calendarRef"
:conflictDays="conflictDays"
@monthChanged="fetchConflictDays"
/>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { lang } from '@Lang/i18n';
// Ruta API
const apiTo = (name, params = {}) => route(`vacation-requests.coordinator.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `vacations.coordinator.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`vacations.${str}`)
export {
viewTo,
apiTo,
transl
}

View File

@ -0,0 +1,181 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { apiTo, viewTo } from './Module'
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Form from './Form.vue';
/** Definidores */
const router = useRouter();
const form = useForm({
periods: [
{
start_date: '',
end_date: '',
number_of_days: 0,
}
],
comments: ''
});
const availableDays = ref(0);
const formRef = ref(null);
// Calcular días restantes
const remainingDays = computed(() => {
if (!formRef.value) return availableDays.value;
return availableDays.value - formRef.value.totalSelectedDays;
});
// Calcular total de días seleccionados
const totalSelectedDays = computed(() => {
if (!formRef.value) return 0;
return formRef.value.totalSelectedDays;
});
// Obtener períodos con datos
const periodsWithData = computed(() => {
return form.periods.filter(period => period.start_date && period.end_date && period.number_of_days > 0);
});
/** Métodos */
function submit() {
form.post(apiTo('store'), {
onSuccess: (res) => {
Notify.success(Lang('created'));
router.push(viewTo({ name: 'index' }));
}
});
}
/** Ciclos */
onMounted(() => {
api.get(route('resources.available-days'), {
onSuccess: (res) => {
availableDays.value = res.available_days;
}
});
});
</script>
<template>
<div>
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-4 mb-4">
<RouterLink :to="viewTo({ name: 'index' })">
<button class="flex items-center justify-center w-10 h-10 rounded-lg bg-slate-100 hover:bg-slate-200 with-transition dark:bg-slate-800 dark:hover:bg-slate-700">
<GoogleIcon name="arrow_back" class="text-slate-600 dark:text-slate-300" />
</button>
</RouterLink>
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Solicitar Vacaciones</h1>
<p class="text-slate-600 dark:text-slate-400">Completa el formulario para solicitar tus días de vacaciones.</p>
</div>
</div>
</div>
<!-- Layout de dos columnas -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Formulario - Columna izquierda (2/3) -->
<div class="lg:col-span-2">
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 dark:bg-slate-800 dark:border-slate-700">
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Detalles de la Solicitud</h2>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-6">Selecciona las fechas y proporciona información adicional.</p>
<Form
ref="formRef"
:form="form"
@submit="submit"
/>
</div>
</div>
<!-- Resumen - Columna derecha (1/3) -->
<div class="lg:col-span-1">
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 dark:bg-slate-800 dark:border-slate-700">
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">Resumen de Solicitud</h3>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-6">Revisa los detalles antes de enviar.</p>
<!-- Días disponibles -->
<div class="text-center mb-6 p-4 bg-slate-50 rounded-lg dark:bg-slate-700/50">
<div class="text-3xl font-bold text-slate-900 dark:text-slate-100">{{ availableDays }}</div>
<div class="text-sm text-slate-600 dark:text-slate-400">Días Disponibles</div>
</div>
<!-- Detalles de períodos -->
<div v-if="periodsWithData.length > 0" class="space-y-4 mb-6">
<div v-for="(period, index) in periodsWithData" :key="index" class="space-y-2">
<h4 class="font-medium text-slate-900 dark:text-slate-100">
Período {{ index + 1 }}:
</h4>
<div class="space-y-1 text-sm text-slate-600 dark:text-slate-400">
<div>Inicio: {{ period.start_date }}</div>
<div>Fin: {{ period.end_date }}</div>
<div class="flex items-center gap-2">
<span>Días en período {{ index + 1 }}:</span>
<span class="px-2 py-1 bg-slate-100 text-slate-700 rounded-full text-xs font-medium dark:bg-slate-600 dark:text-slate-300">
{{ period.number_of_days }} día{{ period.number_of_days > 1 ? 's' : '' }}
</span>
</div>
</div>
</div>
</div>
<!-- Resumen de días -->
<div v-if="totalSelectedDays > 0" class="space-y-3 mb-6">
<div class="flex items-center justify-between">
<span class="text-sm text-slate-600 dark:text-slate-400">Total de días:</span>
<span class="px-2 py-1 bg-slate-100 text-slate-700 rounded-full text-xs font-medium dark:bg-slate-600 dark:text-slate-300">
{{ totalSelectedDays }} día{{ totalSelectedDays > 1 ? 's' : '' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-slate-600 dark:text-slate-400">Días restantes:</span>
<span class="px-2 py-1 rounded-full text-xs font-medium"
:class="remainingDays < 0 ? 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'">
{{ remainingDays }} día{{ remainingDays !== 1 ? 's' : '' }}
</span>
</div>
</div>
<!-- Advertencia si se excede el límite -->
<div v-if="remainingDays < 0" class="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div class="flex items-center gap-2">
<GoogleIcon name="warning" class="w-4 h-4 text-red-600 dark:text-red-400" />
<span class="text-sm text-red-600 dark:text-red-400 font-medium">
Excedes tus días disponibles por {{ Math.abs(remainingDays) }} día{{ Math.abs(remainingDays) > 1 ? 's' : '' }}
</span>
</div>
</div>
<!-- Recordatorios -->
<div class="space-y-3">
<h4 class="font-medium text-slate-900 dark:text-slate-100">Recordatorios:</h4>
<div class="space-y-2 text-sm text-slate-600 dark:text-slate-400">
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Las solicitudes deben hacerse con 15 días de anticipación</span>
</div>
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Tu coordinador revisará y aprobará la solicitud</span>
</div>
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Recibirás una notificación con la decisión</span>
</div>
<div class="flex items-start gap-2">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full mt-2 flex-shrink-0"></div>
<span>Los días se descontarán una vez aprobados</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,236 @@
<script setup>
import { ref, watch, computed } from 'vue';
import VueDatePicker from '@vuepic/vue-datepicker';
import '@vuepic/vue-datepicker/dist/main.css';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
import Textarea from '@Holos/Form/Textarea.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits([
'submit'
])
/** Propiedades */
const props = defineProps({
form: Object
})
/** Métodos */
function submit() {
emit('submit')
}
function addPeriod() {
props.form.periods.push({
start_date: '',
end_date: '',
number_of_days: 0,
});
}
function removePeriod(index) {
if (props.form.periods.length > 1) {
props.form.periods.splice(index, 1);
}
}
function calculateDays(startDate, endDate) {
if (!startDate || !endDate) return 0;
const start = new Date(startDate);
const end = new Date(endDate);
// Validar que la fecha de inicio no sea posterior a la de fin
if (start > end) return 0;
// Calcular la diferencia en días (incluyendo ambos días)
const timeDiff = end.getTime() - start.getTime();
const daysDiff = Math.ceil(timeDiff / (1000 * 3600 * 24)) + 1;
return daysDiff;
}
function updatePeriodDays(index) {
const period = props.form.periods[index];
if (period.start_date && period.end_date) {
period.number_of_days = calculateDays(period.start_date, period.end_date);
} else {
period.number_of_days = 0;
}
}
function getMinDate() {
const today = new Date();
let daysToAdd = 15;
let currentDate = new Date(today);
while (daysToAdd > 0) {
currentDate.setDate(currentDate.getDate() + 1);
// Si no es domingo (0), contar el día
if (currentDate.getDay() !== 0) {
daysToAdd--;
}
}
return currentDate.toISOString().split('T')[0];
}
// Función para desactivar domingos
function isDisabled(date) {
return date.getDay() === 0; // 0 = domingo
}
// Función para formatear fecha a Y-m-d
function formatDate(date) {
if (!date) return '';
// Si la fecha ya es un string en formato correcto, devolverla
if (typeof date === 'string' && date.match(/^\d{4}-\d{2}-\d{2}$/)) {
return date;
}
let d;
if (date instanceof Date) {
d = date;
} else {
d = new Date(date);
}
// Verificar que la fecha es válida
if (isNaN(d.getTime())) {
console.warn('Fecha inválida recibida:', date);
return '';
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Watcher para detectar cambios en las fechas
watch(() => props.form.periods, (newPeriods) => {
newPeriods.forEach((period, index) => {
if (period.start_date && period.end_date) {
updatePeriodDays(index);
}
});
}, { deep: true });
// Calcular total de días seleccionados
const totalSelectedDays = computed(() => {
return props.form.periods.reduce((total, period) => {
return total + (period.number_of_days || 0);
}, 0);
});
// Exponer el total para el componente padre
defineExpose({
totalSelectedDays
});
</script>
<template>
<form @submit.prevent="submit" class="space-y-6">
<!-- Períodos de vacaciones -->
<div class="space-y-4">
<div v-for="(period, index) in form.periods" :key="index" class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-slate-700 dark:text-slate-300">
Período {{ index + 1 }}
</h4>
<button
v-if="form.periods.length > 1"
type="button"
@click="removePeriod(index)"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-md with-transition dark:text-red-400 dark:hover:bg-red-500/10"
>
<GoogleIcon name="delete" class="w-4 h-4" />
Eliminar
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="relative">
<VueDatePicker
v-model="period.start_date"
class="w-full"
placeholder="Fecha de inicio"
format="dd/MM/yyyy"
locale="es"
:min-date="getMinDate()"
:disabled-dates="isDisabled"
:enable-time-picker="false"
:auto-apply="true"
:close-on-auto-apply="true"
@update:model-value="(date) => {
console.log('Fecha seleccionada (inicio):', date);
period.start_date = formatDate(date);
console.log('Fecha formateada (inicio):', period.start_date);
updatePeriodDays(index);
}"
/>
</div>
</div>
<div>
<div class="relative">
<VueDatePicker
v-model="period.end_date"
class="w-full"
placeholder="Fecha de fin"
format="dd/MM/yyyy"
locale="es"
:min-date="getMinDate()"
:disabled-dates="isDisabled"
:enable-time-picker="false"
:auto-apply="true"
:close-on-auto-apply="true"
@update:model-value="(date) => {
console.log('Fecha seleccionada (fin):', date);
period.end_date = formatDate(date);
console.log('Fecha formateada (fin):', period.end_date);
updatePeriodDays(index);
}"
/>
</div>
</div>
</div>
</div>
<!-- Botón añadir período -->
<button
type="button"
@click="addPeriod"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg with-transition dark:text-blue-400 dark:hover:bg-blue-500/10"
>
<GoogleIcon name="add" />
Añadir período
</button>
</div>
<!-- Comentarios -->
<div>
<Textarea
v-model="form.comments"
id="comments"
rows="4"
class="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100"
placeholder="Agrega cualquier información adicional sobre tu solicitud..."
:onError="form.errors.comments"
/>
</div>
<!-- Botón de envío -->
<div class="col-span-1 md:col-span-2 flex justify-center">
<PrimaryButton
v-text="$t('request')"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
/>
</div>
</form>
</template>

View File

@ -0,0 +1,136 @@
<script setup>
import { onMounted, ref } from 'vue';
import { api } from '@Services/Api';
import { getDate } from '@Controllers/DateController';
import { apiTo, viewTo } from './Module'
import GoogleIcon from '@Shared/GoogleIcon.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
/** Propiedades */
const vacationRequests = ref([]);
/** Metodos */
const getStatusClasses = (status) => {
const statusMap = {
'Pendiente': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
'Aprobado': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
'Rechazado': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
'Agendado': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
'Cancelado': 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
'Concluido': 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300'
};
return statusMap[status] || 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300';
};
const getStatusIcon = (status) => {
const iconMap = {
'Pendiente': { name: 'schedule', classes: 'text-amber-600 dark:text-amber-400' },
'Aprobado': { name: 'check_circle', classes: 'text-blue-600 dark:text-blue-400' },
'Rechazado': { name: 'cancel', classes: 'text-orange-600 dark:text-orange-400' },
'Agendado': { name: 'event', classes: 'text-green-600 dark:text-green-400' },
'Cancelado': { name: 'block', classes: 'text-red-600 dark:text-red-400' },
'Concluido': { name: 'task_alt', classes: 'text-cyan-600 dark:text-cyan-400' }
};
return iconMap[status] || { name: 'help', classes: 'text-gray-600 dark:text-gray-400' };
};
const getStatusIconContainer = (status) => {
const containerMap = {
'Pendiente': 'bg-amber-100 dark:bg-amber-900/30',
'Aprobado': 'bg-blue-100 dark:bg-blue-900/30',
'Rechazado': 'bg-orange-100 dark:bg-orange-900/30',
'Agendado': 'bg-green-100 dark:bg-green-900/30',
'Cancelado': 'bg-red-100 dark:bg-red-900/30',
'Concluido': 'bg-cyan-100 dark:bg-cyan-900/30'
};
return containerMap[status] || 'bg-gray-100 dark:bg-gray-900/30';
};
/** Ciclos */
onMounted(() => {
api.get(apiTo('index'), {
onSuccess: (r) => {
vacationRequests.value = r.vacation_requests.data;
}
});
});
</script>
<template>
<div>
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">
Mis Solicitudes
</h1>
<p class="text-slate-600 dark:text-slate-400">Historial completo de todas tus solicitudes de vacaciones.</p>
</div>
<RouterLink :to="viewTo({ name: 'create' })">
<PrimaryButton
type="button"
class="flex items-center gap-2"
>
<GoogleIcon name="calendar_month" />
Nueva Solicitud
</PrimaryButton>
</RouterLink>
</div>
<!-- Lista de solicitudes -->
<div class="space-y-4">
<div v-for="vacation in vacationRequests" class="bg-white rounded-xl border border-slate-200 p-6 shadow-sm dark:bg-slate-800 dark:border-slate-700">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div :class="`flex items-center justify-center w-10 h-10 rounded-lg ${getStatusIconContainer(vacation.status)}`">
<GoogleIcon :name="getStatusIcon(vacation.status).name" :class="getStatusIcon(vacation.status).classes" />
</div>
<div>
<h3 v-if="vacation.vacation_periods.length > 1" class="font-semibold text-slate-900 dark:text-slate-100">{{ vacation.vacation_periods.length }} periodos</h3>
<h3 v-else class="font-semibold text-slate-900 dark:text-slate-100">{{ getDate(vacation.vacation_periods[0].start_date) }} - {{ getDate(vacation.vacation_periods[0].end_date) }}</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">{{ vacation.total_days }} días</p>
</div>
</div>
<span :class="`px-3 py-1 rounded-full text-sm font-medium ${getStatusClasses(vacation.status)}`">
{{ vacation.status }}
</span>
</div>
<div v-for="(period, index) in vacation.vacation_periods" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Período {{ index + 1 }}:</p>
<p class="text-sm text-slate-600 dark:text-slate-400">{{ getDate(period.start_date) }} - {{ getDate(period.end_date) }} ({{ period.number_of_days }} días)</p>
</div>
</div>
<div class="flex justify-end gap-3">
<button class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 hover:bg-slate-100 rounded-lg with-transition dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-700">
<GoogleIcon name="visibility" />
Ver detalles
</button>
<button class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg with-transition dark:text-red-400 dark:hover:bg-red-900/20">
<GoogleIcon name="cancel" />
Cancelar
</button>
</div>
</div>
<!-- Estado vacío cuando no hay solicitudes -->
<div v-if="vacationRequests.length == 0" class="text-center py-12">
<GoogleIcon name="event_busy" class="text-slate-300 text-6xl mb-4 dark:text-slate-600" />
<h3 class="text-lg font-medium text-slate-900 dark:text-slate-100 mb-2">No tienes más solicitudes</h3>
<p class="text-slate-600 dark:text-slate-400 mb-6">Cuando realices nuevas solicitudes de vacaciones aparecerán aquí.</p>
<RouterLink :to="viewTo({ name: 'create' })">
<PrimaryButton>
<GoogleIcon name="add" class="mr-2" />
Nueva Solicitud
</PrimaryButton>
</RouterLink>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { lang } from '@Lang/i18n';
// Ruta API
const apiTo = (name, params = {}) => route(`vacation-requests.employee.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `vacations.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`vacations.${str}`)
export {
viewTo,
apiTo,
transl
}

View File

@ -38,6 +38,26 @@ const router = createRouter({
icon: 'grid_view', icon: 'grid_view',
} }
}, },
{
path: 'vacations',
name: 'vacations',
meta: {
title: 'Vacaciones',
icon: 'calendar_month',
},
children: [
{
path: '',
name: 'vacations.index',
component: () => import('@Pages/Vacations/Employee/Index.vue'),
},
{
path: 'create',
name: 'vacations.create',
component: () => import('@Pages/Vacations/Employee/Create.vue'),
}
]
},
{ {
path: 'profile', path: 'profile',
name: 'profile.show', name: 'profile.show',
@ -85,6 +105,21 @@ const router = createRouter({
} }
] ]
}, },
{
path: 'vacations',
name: 'vacations.coordinator',
meta: {
title: 'Vacaciones',
icon: 'calendar_month',
},
children: [
{
path: '',
name: 'vacations.coordinator.index',
component: () => import('@Pages/Vacations/Coordinator/Index.vue'),
}
]
},
] ]
}, },
{ {
@ -142,6 +177,22 @@ const router = createRouter({
}, },
] ]
}, },
{
path: 'vacations',
name: 'admin.vacations',
meta: {
title: 'Vacaciones',
icon: 'calendar_month',
},
redirect: '/admin/vacations',
children: [
{
path: '',
name: 'admin.vacations.index',
component: () => import('@Pages/Vacations/Admin/Index.vue'),
}
]
},
{ {
path: 'courses', path: 'courses',
name: 'admin.courses', name: 'admin.courses',
@ -306,13 +357,13 @@ const router = createRouter({
] ]
}, },
{ {
path: 'documentation', path: 'maquetador',
name: 'admin.documentation', name: 'admin.maquetador',
meta: { meta: {
title: 'Maquetador de Documentos', title: 'Maquetador de Documentos',
icon: 'documents', icon: 'documents',
}, },
redirect: '/admin/documentation', redirect: '/admin/maquetador',
children: [ children: [
{ {
path: '', path: '',
@ -321,6 +372,15 @@ const router = createRouter({
}, },
] ]
}, },
{
path: 'templates',
name: 'admin.templates.index',
meta: {
title: 'Plantillas',
icon: 'templates',
},
component: () => import('@Pages/Templates/Form.vue'),
},
] ]
}, },
{ {

View File

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

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

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

View File

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