ADD: Remisión

This commit is contained in:
Juan Felipe Zapata Moreno 2025-10-10 16:27:40 -06:00
parent 47764891d2
commit fb0e2e7333
10 changed files with 1402 additions and 71 deletions

View File

@ -20,25 +20,35 @@ const props = defineProps({
DATOS FISCALES CLIENTE DATOS FISCALES CLIENTE
</div> </div>
<div class="border border-gray-300 p-1.5 grid grid-cols-2 gap-x-4"> <div class="border border-gray-300 p-1.5 grid grid-cols-2 gap-x-4">
<!-- Nombre del Cliente (todos los documentos) -->
<div> <div>
<span class="font-semibold">Nombre:</span> <span class="font-semibold">Nombre:</span>
{{ data.clienteNombre }} {{ data.clienteNombre }}
</div> </div>
<!-- Domicilio (todos los documentos usan el mismo campo) -->
<div> <div>
<span class="font-semibold">Domicilio:</span> <span class="font-semibold">Domicilio:</span>
{{ data.clienteDomicilio }} {{ data.clienteDomicilio }}
</div> </div>
<!-- RFC (todos los documentos) -->
<div> <div>
<span class="font-semibold">RFC:</span> {{ data.clienteRFC }} <span class="font-semibold">RFC:</span>
{{ data.clienteRFC }}
</div> </div>
<div v-if="template.documentType === 'COTIZACION'">
<span class="font-semibold">Telefono:</span> <!-- Teléfono (COTIZACION y REMISION) -->
<div v-if="template.documentType === 'COTIZACION' || template.documentType === 'REMISION'">
<span class="font-semibold">Teléfono:</span>
{{ data.clienteTelefono }} {{ data.clienteTelefono }}
</div> </div>
<div v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Régimen Fiscal:</span> {{ data.clienteRegimen }}
<!-- Régimen Fiscal (solo FACTURA) -->
<div v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Régimen Fiscal:</span>
{{ data.clienteRegimen }}
</div> </div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -36,9 +36,7 @@ const props = defineProps({
</div> </div>
<!-- Factura --> <!-- Factura -->
<div <div v-else-if="template.documentType === 'FACTURA'">
v-else-if="template.documentType === 'FACTURA'"
>
<div class="border border-gray-300 p-1.5 grid grid-cols-2 gap-4"> <div class="border border-gray-300 p-1.5 grid grid-cols-2 gap-4">
<div> <div>
<p class="font-semibold">{{ data.empresaNombre }}</p> <p class="font-semibold">{{ data.empresaNombre }}</p>
@ -53,5 +51,17 @@ const props = defineProps({
</div> </div>
</div> </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> </div>
</template> </template>

View File

@ -14,7 +14,7 @@ const getTipoFolioLabel = (documentType) => {
const labels = { const labels = {
COTIZACION: "Número de Folio:", COTIZACION: "Número de Folio:",
FACTURA: "Folio:", FACTURA: "Folio:",
REMISION: "Número de Remisión:", REMISION: "Folio:",
}; };
return labels[documentType] || "Número de Folio:"; return labels[documentType] || "Número de Folio:";
}; };
@ -54,7 +54,7 @@ const getTipoFolioLabel = (documentType) => {
{{ template.documentType || "COTIZACION" }} {{ template.documentType || "COTIZACION" }}
</h2> </h2>
<div class="space-y-0.5"> <div class="space-y-0.5">
<p v-if="template.documentType === 'FACTURA'"> <p v-if="template.documentType === 'FACTURA' || template.documentType === 'REMISION'">
<span class="font-semibold"> Serie: </span> <span class="font-semibold"> Serie: </span>
{{ data.serie }} {{ data.serie }}
</p> </p>
@ -68,13 +68,17 @@ const getTipoFolioLabel = (documentType) => {
<span class="font-semibold">Fecha de Emisión:</span> <span class="font-semibold">Fecha de Emisión:</span>
{{ data.fechaEmision }} {{ data.fechaEmision }}
</p> </p>
<p v-else-if="template.documentType === 'REMISION'">
<span class="font-semibold">Fecha de Remisión:</span>
{{ data.fechaRemision }}
</p>
<p v-else> <p v-else>
<span class="font-semibold">Fecha de Realización:</span> <span class="font-semibold">Fecha de Realización:</span>
{{ data.fechaRealizacion }} {{ data.fechaRealizacion }}
</p> </p>
<p v-if="template.documentType !== 'FACTURA'"> <p v-if="template.documentType === 'COTIZACION'">
<span class="font-semibold">Vigencia::</span> <span class="font-semibold">Vigencia:</span>
{{ data.vigencia }} {{ data.vigencia }}
</p> </p>
<p v-if="template.documentType === 'FACTURA'"> <p v-if="template.documentType === 'FACTURA'">

View File

@ -1,4 +1,6 @@
<script setup> <script setup>
import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
template: { template: {
type: Object, type: Object,
@ -16,10 +18,36 @@ const formatCurrency = (value) => {
currency: "MXN", currency: "MXN",
}).format(value); }).format(value);
}; };
const isRemision = computed(() => {
return props.template.documentType === 'REMISION';
});
</script> </script>
<template> <template>
<table class="w-full border-collapse mb-3"> <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> <thead>
<tr <tr
class="text-white" class="text-white"

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

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

@ -1,44 +1,59 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, watch, onMounted } from "vue";
import { usePDFExport } from "@Pages/Templates/Configs/usePDFExport"; import { usePDFExport } from "@Pages/Templates/Configs/usePDFExport";
import { useDocument } from "@/composables/useDocument";
import ConfigCotizacion from "@Pages/Templates/Configs/ConfigCotizacion.js"; import ConfigCotizacion from "@Pages/Templates/Configs/ConfigCotizacion.js";
import ConfigFacturacion from "@Pages/Templates/Configs/ConfigFacturacion.js"; import ConfigFacturacion from "@Pages/Templates/Configs/ConfigFacturacion.js";
import Document from "./DocumentTemplate.vue"; import ConfigRemision from "@Pages/Templates/Configs/ConfigRemision.js";
import ProductTable from "@Holos/DocumentSection/CotizacionTable.vue"; import Document from "./DocumentTemplate.vue";
import FacturaTable from "@Holos/DocumentSection/FacturacionTable.vue"; import ProductTable from "@Holos/DocumentSection/CotizacionTable.vue";
import Input from "@Holos/Form/Input.vue"; import FacturaTable from "@Holos/DocumentSection/FacturacionTable.vue";
import Textarea from "@Holos/Form/Textarea.vue"; import RemisionTable from "@Holos/DocumentSection/RemisionTable.vue";
import PrimaryButton from "@Holos/Button/Primary.vue"; import Input from "@Holos/Form/Input.vue";
import GoogleIcon from "@Shared/GoogleIcon.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 */ /** Composables */
const { exportToPDF, isExporting } = usePDFExport(); const { exportToPDF, isExporting } = usePDFExport();
/** Usar configuración importada */ 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 templateConfig = ref(ConfigCotizacion);
const showPreview = ref(true); const showPreview = ref(true);
/** Estado */
const formData = ref({});
const branding = ref({
documentType: "COTIZACION",
...templateConfig.value.branding,
});
const productos = ref([]);
const totales = ref({
subtotal1: 0,
descuentoTotal: 0,
subtotal2: 0,
iva: 0,
subtotal: 0,
impuestosTrasladados: 0,
total: 0,
});
/** Computed */ /** Computed */
const documentData = computed(() => { const documentData = computed(() => {
return { return {
@ -56,31 +71,14 @@ const documentData = computed(() => {
}); });
/** Métodos */ /** Métodos */
const initializeForm = () => { const handleLogoUpload = async (file) => {
const data = {};
templateConfig.value.campos.forEach((seccion) => {
seccion.campos.forEach((campo) => {
data[campo.key] = campo.defaultValue || "";
});
});
formData.value = data;
};
const handleLogoUpload = (file) => {
if (!file) { if (!file) {
console.warn("No se seleccionó archivo"); console.warn("No se seleccionó archivo");
return; return;
} }
console.log("Archivo seleccionado:", file.name, file.type); console.log("Archivo seleccionado:", file.name, file.type);
branding.value.logo = file; await uploadLogo(file);
const reader = new FileReader();
reader.onload = (e) => {
branding.value.logoPreview = e.target.result;
};
reader.onerror = (error) => {};
reader.readAsDataURL(file);
}; };
const handleTotalsUpdate = (newTotals) => { const handleTotalsUpdate = (newTotals) => {
@ -94,29 +92,92 @@ const handleExport = () => {
); );
}; };
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( watch(
() => branding.value.documentType, () => branding.value.documentType,
(newType) => { (newType) => {
if (newType === "FACTURA") { if (newType === "FACTURA") {
templateConfig.value = ConfigFacturacion; templateConfig.value = ConfigFacturacion;
} else if (newType === "REMISION") {
templateConfig.value = ConfigRemision;
} else { } else {
templateConfig.value = ConfigCotizacion; templateConfig.value = ConfigCotizacion;
} }
initializeForm(); initializeForm(templateConfig.value);
productos.value = []; productos.value = [];
} }
); );
/** Ciclos */ /** Ciclos de vida */
onMounted(() => { onMounted(async () => {
initializeForm(); // 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> </script>
<template> <template>
<div class="p-6"> <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 --> <!-- Header -->
<div class="mb-6 flex items-center justify-between gap-4"> <div class="mb-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@ -126,6 +187,12 @@ onMounted(() => {
</h2> </h2>
<p class="text-sm text-gray-600 dark:text-primary-dt/70"> <p class="text-sm text-gray-600 dark:text-primary-dt/70">
Genera documento PDF 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> </p>
</div> </div>
@ -149,6 +216,17 @@ onMounted(() => {
</span> </span>
</button> </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 <PrimaryButton
@click="handleExport" @click="handleExport"
:disabled="isExporting" :disabled="isExporting"
@ -169,7 +247,8 @@ onMounted(() => {
</label> </label>
<select <select
v-model="branding.documentType" v-model="branding.documentType"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 font-medium dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt" :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="COTIZACION">Cotización</option>
<option value="FACTURA">Factura</option> <option value="FACTURA">Factura</option>
@ -231,6 +310,7 @@ onMounted(() => {
@click=" @click="
branding.logoPreview = null; branding.logoPreview = null;
branding.logo = null; branding.logo = null;
branding.logoUrl = null;
" "
type="button" type="button"
class="ml-auto text-red-500 hover:text-red-700 dark:text-red-400" class="ml-auto text-red-500 hover:text-red-700 dark:text-red-400"
@ -347,6 +427,12 @@ onMounted(() => {
@update:totals="handleTotalsUpdate" @update:totals="handleTotalsUpdate"
/> />
<RemisionTable
v-if="branding.documentType === 'REMISION'"
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
<ProductTable <ProductTable
v-else v-else
v-model="productos" v-model="productos"
@ -387,4 +473,4 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</template> </template>

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