ADD: Remisión
This commit is contained in:
parent
47764891d2
commit
fb0e2e7333
@ -20,24 +20,34 @@ const props = defineProps({
|
||||
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 }}
|
||||
<span class="font-semibold">RFC:</span>
|
||||
{{ data.clienteRFC }}
|
||||
</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 }}
|
||||
</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>
|
||||
|
||||
@ -36,9 +36,7 @@ const props = defineProps({
|
||||
</div>
|
||||
|
||||
<!-- Factura -->
|
||||
<div
|
||||
v-else-if="template.documentType === '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>
|
||||
@ -53,5 +51,17 @@ const props = defineProps({
|
||||
</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>
|
||||
|
||||
@ -14,7 +14,7 @@ const getTipoFolioLabel = (documentType) => {
|
||||
const labels = {
|
||||
COTIZACION: "Número de Folio:",
|
||||
FACTURA: "Folio:",
|
||||
REMISION: "Número de Remisión:",
|
||||
REMISION: "Folio:",
|
||||
};
|
||||
return labels[documentType] || "Número de Folio:";
|
||||
};
|
||||
@ -54,7 +54,7 @@ const getTipoFolioLabel = (documentType) => {
|
||||
{{ template.documentType || "COTIZACION" }}
|
||||
</h2>
|
||||
<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>
|
||||
{{ data.serie }}
|
||||
</p>
|
||||
@ -68,13 +68,17 @@ const getTipoFolioLabel = (documentType) => {
|
||||
<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 !== 'FACTURA'">
|
||||
<span class="font-semibold">Vigencia::</span>
|
||||
<p v-if="template.documentType === 'COTIZACION'">
|
||||
<span class="font-semibold">Vigencia:</span>
|
||||
{{ data.vigencia }}
|
||||
</p>
|
||||
<p v-if="template.documentType === 'FACTURA'">
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
@ -16,10 +18,36 @@ const formatCurrency = (value) => {
|
||||
currency: "MXN",
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const isRemision = computed(() => {
|
||||
return props.template.documentType === 'REMISION';
|
||||
});
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<tr
|
||||
class="text-white"
|
||||
|
||||
259
src/components/Holos/DocumentSection/RemisionTable.vue
Normal file
259
src/components/Holos/DocumentSection/RemisionTable.vue
Normal file
@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:totals']);
|
||||
|
||||
const productos = ref([...props.modelValue]);
|
||||
|
||||
const unidades = [
|
||||
{ codigo: 'PIEZA', nombre: 'Pieza' },
|
||||
{ codigo: 'CAJA', nombre: 'Caja' },
|
||||
{ codigo: 'PAQUETE', nombre: 'Paquete' },
|
||||
{ codigo: 'KG', nombre: 'Kilogramo' },
|
||||
{ codigo: 'METRO', nombre: 'Metro' },
|
||||
{ codigo: 'LITRO', nombre: 'Litro' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Agregar nuevo producto
|
||||
*/
|
||||
const agregarProducto = () => {
|
||||
productos.value.push({
|
||||
cantidad: 1,
|
||||
unidad: 'PIEZA',
|
||||
codigo: '',
|
||||
descripcion: '',
|
||||
precioUnitario: 0,
|
||||
});
|
||||
actualizarProductos();
|
||||
};
|
||||
|
||||
/**
|
||||
* Eliminar producto
|
||||
*/
|
||||
const eliminarProducto = (index) => {
|
||||
productos.value.splice(index, 1);
|
||||
actualizarProductos();
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcular importe total
|
||||
*/
|
||||
const calcularImporte = (producto) => {
|
||||
return producto.cantidad * producto.precioUnitario;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcular totales generales
|
||||
*/
|
||||
const calculos = computed(() => {
|
||||
const total = productos.value.reduce((sum, producto) => {
|
||||
return sum + calcularImporte(producto);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
subtotal: total,
|
||||
iva: 0,
|
||||
descuentoTotal: 0,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Actualizar productos y emitir cambios
|
||||
*/
|
||||
const actualizarProductos = () => {
|
||||
emit('update:modelValue', productos.value);
|
||||
emit('update:totals', calculos.value);
|
||||
};
|
||||
|
||||
// Watch para actualizar cuando cambian los productos
|
||||
watch(productos, () => {
|
||||
actualizarProductos();
|
||||
}, { deep: true });
|
||||
|
||||
// Watch para sincronizar con el v-model externo
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(productos.value)) {
|
||||
productos.value = [...newVal];
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* Formatear moneda
|
||||
*/
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(value || 0);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20">
|
||||
<!-- ENCABEZADO -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-primary-dt">
|
||||
Productos / Servicios
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-primary-dt/60">
|
||||
Listado de productos en la remisión
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="agregarProducto"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<GoogleIcon name="add" class="text-lg" />
|
||||
Agregar Producto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PRODUCTOS -->
|
||||
<div v-if="productos.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(producto, index) in productos"
|
||||
:key="index"
|
||||
class="border-2 border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors dark:border-primary/20 dark:hover:border-blue-500"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-blue-100 text-blue-700 font-bold px-3 py-1 rounded text-sm dark:bg-blue-900/30 dark:text-blue-300">
|
||||
#{{ index + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="eliminarProducto(index)"
|
||||
type="button"
|
||||
class="text-red-500 hover:text-red-700 p-2 hover:bg-red-50 rounded-lg transition-colors dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
title="Eliminar producto"
|
||||
>
|
||||
<GoogleIcon name="delete" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grid de campos -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- CANTIDAD -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Cantidad <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="producto.cantidad"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- UNIDAD -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Unidad <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="producto.unidad"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option v-for="unidad in unidades" :key="unidad.codigo" :value="unidad.codigo">
|
||||
{{ unidad.nombre }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- CÓDIGO -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Código
|
||||
</label>
|
||||
<input
|
||||
v-model="producto.codigo"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="SKU-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- PRECIO UNITARIO -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Precio Unit. <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="producto.precioUnitario"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESCRIPCIÓN -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
|
||||
Descripción <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="producto.descripcion"
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500 resize-y"
|
||||
placeholder="Descripción detallada del producto..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- IMPORTE -->
|
||||
<div class="mt-3 flex justify-end">
|
||||
<div class="text-right">
|
||||
<span class="text-xs text-gray-500 dark:text-primary-dt/70">Importe:</span>
|
||||
<div class="text-lg font-bold text-blue-700 dark:text-blue-400">
|
||||
{{ formatCurrency(calcularImporte(producto)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estado vacío -->
|
||||
<div v-else class="text-center py-12 text-gray-500 dark:text-primary-dt/70">
|
||||
<GoogleIcon name="inventory_2" class="text-5xl mb-3 opacity-50" />
|
||||
<p class="text-sm font-semibold">No hay productos agregados</p>
|
||||
<p class="text-xs">Haz clic en "Agregar Producto" para comenzar</p>
|
||||
</div>
|
||||
|
||||
<!-- RESUMEN DE TOTALES -->
|
||||
<div v-if="productos.length > 0" class="mt-6 flex justify-end">
|
||||
<div class="bg-blue-50 rounded-lg p-4 min-w-[250px] dark:bg-blue-900/20">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-primary-dt">
|
||||
Total:
|
||||
</span>
|
||||
<span class="text-xl font-bold text-blue-700 dark:text-blue-400">
|
||||
{{ formatCurrency(calculos.total) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
464
src/composables/useDocument.js
Normal file
464
src/composables/useDocument.js
Normal file
@ -0,0 +1,464 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useForm, useApi } from '@/services/Api';
|
||||
import { documentService } from '@/services/documentService';
|
||||
|
||||
/**
|
||||
* Composable para manejar documentos
|
||||
*
|
||||
*/
|
||||
export function useDocument(config = {}) {
|
||||
// ============================================
|
||||
// ESTADO
|
||||
// ============================================
|
||||
const documentId = ref(config.documentId || null);
|
||||
const documentType = ref(config.documentType || 'COTIZACION');
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Documento completo
|
||||
const document = ref(null);
|
||||
|
||||
// Formulario de datos
|
||||
const formData = ref({});
|
||||
const branding = ref({
|
||||
documentType: documentType.value,
|
||||
primaryColor: '#2c50dd',
|
||||
slogan: '',
|
||||
logoUrl: null,
|
||||
logoPreview: null
|
||||
});
|
||||
const productos = ref([]);
|
||||
const totales = ref({
|
||||
// Totales principales
|
||||
subtotal: 0,
|
||||
iva: 0,
|
||||
total: 0,
|
||||
|
||||
// Totales adicionales
|
||||
subtotal1: 0,
|
||||
descuentoTotal: 0,
|
||||
subtotal2: 0,
|
||||
impuestosTrasladados: 0
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// COMPUTED
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Transforma datos
|
||||
*/
|
||||
const documentPayload = computed(() => {
|
||||
return {
|
||||
tipo: documentType.value,
|
||||
estado: 'BORRADOR',
|
||||
templateConfig: {
|
||||
primaryColor: branding.value.primaryColor,
|
||||
logoUrl: branding.value.logoUrl,
|
||||
slogan: branding.value.slogan
|
||||
},
|
||||
datos: {
|
||||
// Datos de la empresa
|
||||
empresa: {
|
||||
nombre: formData.value.empresaNombre || '',
|
||||
rfc: formData.value.empresaRFC || '',
|
||||
email: formData.value.empresaEmail || '',
|
||||
telefono: formData.value.empresaTelefono || '',
|
||||
direccion: formData.value.empresaDireccion || '',
|
||||
web: formData.value.empresaWeb || '',
|
||||
|
||||
// Campos específicos para facturas (CFDI)
|
||||
lugar: formData.value.empresaLugar || '',
|
||||
cfdi: formData.value.empresaCfdi || '',
|
||||
regimen: formData.value.empresaRegimen || ''
|
||||
},
|
||||
|
||||
// Datos bancarios (para cotizaciones)
|
||||
bancos: {
|
||||
banco: formData.value.bancoBanco || '',
|
||||
tipoCuenta: formData.value.bancoTipoCuenta || '',
|
||||
cuenta: formData.value.bancoCuenta || ''
|
||||
},
|
||||
|
||||
// Datos del cliente
|
||||
cliente: {
|
||||
nombre: formData.value.clienteNombre || '',
|
||||
rfc: formData.value.clienteRFC || '',
|
||||
domicilio: formData.value.clienteDomicilio || '',
|
||||
telefono: formData.value.clienteTelefono || '',
|
||||
|
||||
// Campo específico para facturas
|
||||
regimen: formData.value.clienteRegimen || ''
|
||||
},
|
||||
|
||||
// Datos del ejecutivo (para cotizaciones)
|
||||
ejecutivo: {
|
||||
nombre: formData.value.ejecutivoNombre || '',
|
||||
correo: formData.value.ejecutivoCorreo || '',
|
||||
celular: formData.value.ejecutivoCelular || ''
|
||||
},
|
||||
|
||||
// Detalles del documento
|
||||
documento: {
|
||||
// Común para todos
|
||||
folio: formData.value.folio || '',
|
||||
observaciones: formData.value.observaciones || '',
|
||||
|
||||
// Específico para cotizaciones
|
||||
fechaRealizacion: formData.value.fechaRealizacion || '',
|
||||
vigencia: formData.value.vigencia || '',
|
||||
|
||||
// Específico para facturas
|
||||
serie: formData.value.serie || '',
|
||||
fechaEmision: formData.value.fechaEmision || '',
|
||||
tipoComprobante: formData.value.tipoComprobante || '',
|
||||
|
||||
// Específico para remisiones
|
||||
fechaRemision: formData.value.fechaRemision || ''
|
||||
},
|
||||
|
||||
// Productos y totales
|
||||
productos: productos.value,
|
||||
totales: totales.value
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// MÉTODOS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Cargar documento existente
|
||||
*/
|
||||
const loadDocument = async (id) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const config = documentService.getById(id);
|
||||
const api = useApi();
|
||||
|
||||
await api.load({
|
||||
...config,
|
||||
options: {
|
||||
onSuccess: (data) => {
|
||||
document.value = data;
|
||||
hydrateForm(data);
|
||||
},
|
||||
onFail: (err) => {
|
||||
error.value = err.message || 'Error al cargar documento';
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hidratar formulario con datos del servidor
|
||||
*
|
||||
* Llena todos los campos del formulario con los datos de un documento cargado
|
||||
*
|
||||
*/
|
||||
const hydrateForm = (documentData) => {
|
||||
documentType.value = documentData.tipo;
|
||||
|
||||
// Actualizar branding/template
|
||||
branding.value = {
|
||||
primaryColor: documentData.templateConfig.primaryColor || '#2c50dd',
|
||||
slogan: documentData.templateConfig.slogan || '',
|
||||
logoUrl: documentData.templateConfig.logoUrl || null,
|
||||
logoPreview: documentData.templateConfig.logoUrl || null
|
||||
};
|
||||
|
||||
// Llenar todos los campos del formulario
|
||||
formData.value = {
|
||||
// Datos de la empresa
|
||||
empresaNombre: documentData.datos.empresa.nombre || '',
|
||||
empresaRFC: documentData.datos.empresa.rfc || '',
|
||||
empresaEmail: documentData.datos.empresa.email || '',
|
||||
empresaTelefono: documentData.datos.empresa.telefono || '',
|
||||
empresaDireccion: documentData.datos.empresa.direccion || '',
|
||||
empresaWeb: documentData.datos.empresa.web || '',
|
||||
|
||||
// Campos específicos de facturas
|
||||
empresaLugar: documentData.datos.empresa.lugar || '',
|
||||
empresaCfdi: documentData.datos.empresa.cfdi || '',
|
||||
empresaRegimen: documentData.datos.empresa.regimen || '',
|
||||
|
||||
// Datos bancarios (puede no existir en facturas)
|
||||
bancoBanco: documentData.datos.bancos?.banco || '',
|
||||
bancoTipoCuenta: documentData.datos.bancos?.tipoCuenta || '',
|
||||
bancoCuenta: documentData.datos.bancos?.cuenta || '',
|
||||
|
||||
// Datos del cliente
|
||||
clienteNombre: documentData.datos.cliente.nombre || '',
|
||||
clienteRFC: documentData.datos.cliente.rfc || '',
|
||||
clienteDomicilio: documentData.datos.cliente.domicilio || '',
|
||||
clienteTelefono: documentData.datos.cliente.telefono || '',
|
||||
clienteRegimen: documentData.datos.cliente.regimen || '',
|
||||
|
||||
// Datos del ejecutivo (puede no existir en facturas)
|
||||
ejecutivoNombre: documentData.datos.ejecutivo?.nombre || '',
|
||||
ejecutivoCorreo: documentData.datos.ejecutivo?.correo || '',
|
||||
ejecutivoCelular: documentData.datos.ejecutivo?.celular || '',
|
||||
|
||||
// Detalles del documento
|
||||
folio: documentData.datos.documento?.folio || '',
|
||||
observaciones: documentData.datos.documento?.observaciones || '',
|
||||
|
||||
// Específico para cotizaciones
|
||||
fechaRealizacion: documentData.datos.documento?.fechaRealizacion || '',
|
||||
vigencia: documentData.datos.documento?.vigencia || '',
|
||||
|
||||
// Específico para facturas
|
||||
serie: documentData.datos.documento?.serie || '',
|
||||
fechaEmision: documentData.datos.documento?.fechaEmision || '',
|
||||
tipoComprobante: documentData.datos.documento?.tipoComprobante || '',
|
||||
|
||||
// Específico para remisiones
|
||||
fechaRemision: documentData.datos.documento?.fechaRemision || '',
|
||||
};
|
||||
|
||||
// Productos y totales
|
||||
productos.value = documentData.datos.productos || [];
|
||||
totales.value = documentData.datos.totales || {
|
||||
subtotal: 0,
|
||||
iva: 0,
|
||||
total: 0
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Inicializar formulario vacío desde configuración de template
|
||||
*
|
||||
*/
|
||||
const initializeForm = (templateConfig) => {
|
||||
const data = {};
|
||||
|
||||
templateConfig.campos.forEach((seccion) => {
|
||||
seccion.campos.forEach((campo) => {
|
||||
data[campo.key] = campo.defaultValue || '';
|
||||
});
|
||||
});
|
||||
|
||||
formData.value = data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Guardar documento (crear o actualizar)
|
||||
*/
|
||||
const saveDocument = async (options = {}) => {
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const config = documentService.save(
|
||||
documentPayload.value,
|
||||
documentId.value
|
||||
);
|
||||
|
||||
const form = useForm(documentPayload.value);
|
||||
|
||||
await form.load({
|
||||
...config,
|
||||
options: {
|
||||
onSuccess: (data) => {
|
||||
document.value = data;
|
||||
documentId.value = data.id;
|
||||
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess(data);
|
||||
}
|
||||
},
|
||||
onFail: (err) => {
|
||||
error.value = err.message || 'Error al guardar documento';
|
||||
|
||||
if (options.onFail) {
|
||||
options.onFail(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-guardar (debounced)
|
||||
*/
|
||||
let autoSaveTimeout = null;
|
||||
const autoSave = () => {
|
||||
clearTimeout(autoSaveTimeout);
|
||||
autoSaveTimeout = setTimeout(() => {
|
||||
saveDocument();
|
||||
}, 2000); // Guardar 2 segundos después del último cambio
|
||||
};
|
||||
|
||||
/**
|
||||
* Subir logo
|
||||
*
|
||||
* (usa FileReader temporalmente)
|
||||
*
|
||||
*/
|
||||
const uploadLogo = async (file) => {
|
||||
if (!file || !(file instanceof File)) {
|
||||
error.value = 'Archivo inválido';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Cuando el backend esté listo, descomentar esto
|
||||
/*
|
||||
const config = documentService.uploadLogo(file);
|
||||
const form = useForm({ logo: file });
|
||||
|
||||
await form.load({
|
||||
...config,
|
||||
options: {
|
||||
onSuccess: (data) => {
|
||||
branding.value.logoUrl = data.logoUrl;
|
||||
branding.value.logoPreview = data.logoUrl;
|
||||
},
|
||||
onFail: (failData) => {
|
||||
error.value = failData.message || 'Error al subir logo';
|
||||
}
|
||||
}
|
||||
});
|
||||
*/
|
||||
|
||||
// FALLBACK: Mientras no hay backend, usar FileReader
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
branding.value.logoPreview = e.target.result;
|
||||
branding.value.logo = file; // Guardar el archivo original
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
error.value = 'Error al leer el archivo de imagen';
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Error al procesar logo';
|
||||
console.error('Error uploadLogo:', err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generar PDF del documento
|
||||
*
|
||||
* Si el documento no está guardado, lo guarda primero
|
||||
* y verifica que el guardado sea exitoso antes de generar PDF
|
||||
*/
|
||||
const generatePDF = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Si no hay documentId, guardar primero
|
||||
if (!documentId.value) {
|
||||
// Guardar y esperar confirmación
|
||||
await new Promise((resolve, reject) => {
|
||||
saveDocument({
|
||||
onSuccess: () => {
|
||||
resolve();
|
||||
},
|
||||
onFail: (failData) => {
|
||||
reject(new Error(failData.message || 'Error al guardar documento'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Verificar que ahora sí tenemos documentId
|
||||
if (!documentId.value) {
|
||||
throw new Error('No se pudo obtener el ID del documento guardado');
|
||||
}
|
||||
}
|
||||
|
||||
// Ahora generar PDF
|
||||
const config = documentService.generatePDF(documentId.value);
|
||||
const api = useApi();
|
||||
|
||||
await api.load({
|
||||
...config,
|
||||
options: {
|
||||
onSuccess: (data) => {
|
||||
if (data.pdfUrl) {
|
||||
window.open(data.pdfUrl, '_blank');
|
||||
} else {
|
||||
error.value = 'No se recibió la URL del PDF';
|
||||
}
|
||||
},
|
||||
onFail: (failData) => {
|
||||
error.value = failData.message || 'Error al generar PDF';
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Error al generar PDF';
|
||||
console.error('Error generatePDF:', err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// WATCHERS
|
||||
// ============================================
|
||||
|
||||
// Sincronizar documentType con branding.documentType
|
||||
watch(documentType, (newType) => {
|
||||
branding.value.documentType = newType;
|
||||
});
|
||||
|
||||
// Auto-save cuando cambian los datos (solo si ya existe documentId)
|
||||
watch([formData, productos, totales], () => {
|
||||
if (documentId.value) {
|
||||
autoSave();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// ============================================
|
||||
// RETORNO
|
||||
// ============================================
|
||||
|
||||
return {
|
||||
// Estado
|
||||
documentId,
|
||||
documentType,
|
||||
document,
|
||||
formData,
|
||||
branding,
|
||||
productos,
|
||||
totales,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
documentPayload,
|
||||
|
||||
// Métodos
|
||||
initializeForm,
|
||||
loadDocument,
|
||||
saveDocument,
|
||||
uploadLogo,
|
||||
generatePDF,
|
||||
autoSave
|
||||
};
|
||||
}
|
||||
95
src/pages/Templates/Configs/ConfigRemision.js
Normal file
95
src/pages/Templates/Configs/ConfigRemision.js
Normal file
@ -0,0 +1,95 @@
|
||||
export default{
|
||||
templateId: 'temp-rem-001',
|
||||
nombre: 'Remisión',
|
||||
|
||||
branding: {
|
||||
logo: null,
|
||||
primaryColor: '#2c50dd',
|
||||
slogan: ' ',
|
||||
},
|
||||
|
||||
campos:[
|
||||
{
|
||||
seccion: 'Datos de la Empresa',
|
||||
campos: [
|
||||
{
|
||||
key: 'empresaNombre',
|
||||
label: 'Nombre de la Empresa',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
placeholder: 'Ej: GOLSYSTEMS',
|
||||
},
|
||||
{
|
||||
key: 'empresaRFC',
|
||||
label: 'RFC',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
placeholder: 'Ej: ABC123456789',
|
||||
},
|
||||
{
|
||||
key: 'empresaDireccion',
|
||||
label: 'Dirección',
|
||||
tipo: 'textarea',
|
||||
required: true,
|
||||
placeholder: 'Dirección completa',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
seccion : 'Datos del Cliente',
|
||||
campos: [
|
||||
{
|
||||
key: 'clienteNombre',
|
||||
label: 'Nombre del Cliente',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
placeholder: 'Ej: Juan Pérez',
|
||||
},
|
||||
{
|
||||
key: 'clienteRFC',
|
||||
label: 'RFC',
|
||||
tipo: 'text',
|
||||
required: false,
|
||||
placeholder: 'Ej: ABC123456789',
|
||||
},
|
||||
{
|
||||
key: 'clienteTelefono',
|
||||
label: 'Teléfono',
|
||||
tipo: 'text',
|
||||
required: false,
|
||||
placeholder: 'Ej: 555-123-4567',
|
||||
},
|
||||
{
|
||||
key: 'clienteDomicilio',
|
||||
label: 'Dirección',
|
||||
tipo: 'textarea',
|
||||
required: true,
|
||||
placeholder: 'Dirección completa',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
seccion: 'Detalles del Documento',
|
||||
campos: [
|
||||
{
|
||||
key: 'serie',
|
||||
label: 'Número de Serie',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'folio',
|
||||
label: 'Número de Folio',
|
||||
tipo: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'fechaRemision',
|
||||
label: 'Fecha',
|
||||
tipo: 'date',
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,44 +1,59 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
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();
|
||||
|
||||
/** 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 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 */
|
||||
const documentData = computed(() => {
|
||||
return {
|
||||
@ -56,31 +71,14 @@ const documentData = computed(() => {
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const initializeForm = () => {
|
||||
const data = {};
|
||||
templateConfig.value.campos.forEach((seccion) => {
|
||||
seccion.campos.forEach((campo) => {
|
||||
data[campo.key] = campo.defaultValue || "";
|
||||
});
|
||||
});
|
||||
formData.value = data;
|
||||
};
|
||||
|
||||
const handleLogoUpload = (file) => {
|
||||
const handleLogoUpload = async (file) => {
|
||||
if (!file) {
|
||||
console.warn("No se seleccionó archivo");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Archivo seleccionado:", file.name, file.type);
|
||||
branding.value.logo = file;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
branding.value.logoPreview = e.target.result;
|
||||
};
|
||||
reader.onerror = (error) => {};
|
||||
reader.readAsDataURL(file);
|
||||
await uploadLogo(file);
|
||||
};
|
||||
|
||||
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(
|
||||
() => branding.value.documentType,
|
||||
(newType) => {
|
||||
if (newType === "FACTURA") {
|
||||
templateConfig.value = ConfigFacturacion;
|
||||
} else if (newType === "REMISION") {
|
||||
templateConfig.value = ConfigRemision;
|
||||
} else {
|
||||
templateConfig.value = ConfigCotizacion;
|
||||
}
|
||||
|
||||
initializeForm();
|
||||
|
||||
initializeForm(templateConfig.value);
|
||||
productos.value = [];
|
||||
}
|
||||
);
|
||||
|
||||
/** Ciclos */
|
||||
onMounted(() => {
|
||||
initializeForm();
|
||||
/** 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">
|
||||
@ -126,6 +187,12 @@ onMounted(() => {
|
||||
</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>
|
||||
|
||||
@ -149,6 +216,17 @@ onMounted(() => {
|
||||
</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"
|
||||
@ -169,7 +247,8 @@ onMounted(() => {
|
||||
</label>
|
||||
<select
|
||||
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="FACTURA">Factura</option>
|
||||
@ -231,6 +310,7 @@ onMounted(() => {
|
||||
@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"
|
||||
@ -347,6 +427,12 @@ onMounted(() => {
|
||||
@update:totals="handleTotalsUpdate"
|
||||
/>
|
||||
|
||||
<RemisionTable
|
||||
v-if="branding.documentType === 'REMISION'"
|
||||
v-model="productos"
|
||||
@update:totals="handleTotalsUpdate"
|
||||
/>
|
||||
|
||||
<ProductTable
|
||||
v-else
|
||||
v-model="productos"
|
||||
|
||||
102
src/services/documentService.js
Normal file
102
src/services/documentService.js
Normal file
@ -0,0 +1,102 @@
|
||||
import { apiURL } from '@/services/Api';
|
||||
|
||||
/**
|
||||
* Servicio para gestión de documentos
|
||||
*/
|
||||
export const documentService = {
|
||||
/**
|
||||
* Guardar documento (crear o actualizar)
|
||||
*
|
||||
*/
|
||||
save(payload, documentId = null) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new Error('Payload es requerido y debe ser un objeto');
|
||||
}
|
||||
|
||||
if (documentId !== null && (typeof documentId !== 'number' && typeof documentId !== 'string')) {
|
||||
throw new Error('documentId debe ser un número o string');
|
||||
}
|
||||
const method = documentId ? 'put' : 'post';
|
||||
const url = documentId
|
||||
? apiURL(`documents/${documentId}`)
|
||||
: apiURL('documents');
|
||||
|
||||
return {
|
||||
method,
|
||||
url,
|
||||
data: payload
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener documento por ID
|
||||
*
|
||||
*/
|
||||
getById(documentId) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: apiURL(`documents/${documentId}`)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Listar documentos con filtros
|
||||
*
|
||||
*/
|
||||
list(filters = {}) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: apiURL('documents'),
|
||||
params: filters
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Generar PDF del documento
|
||||
*
|
||||
*/
|
||||
generatePDF(documentId, options = {}) {
|
||||
if (!documentId) {
|
||||
throw new Error('Id es requerido');
|
||||
}
|
||||
|
||||
const config = {
|
||||
method: 'post',
|
||||
url: apiURL(`documents/${documentId}/generate-pdf`)
|
||||
};
|
||||
|
||||
if (Object.keys(options).length > 0) {
|
||||
config.data = {
|
||||
format: options.format || 'A4',
|
||||
orientation: options.orientation || 'portrait'
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* Subir logo
|
||||
*
|
||||
*/
|
||||
uploadLogo(file) {
|
||||
if (!(file instanceof File)) {
|
||||
throw new Error('El parámetro debe ser un archivo (File)');
|
||||
}
|
||||
return {
|
||||
method: 'post',
|
||||
url: apiURL('documents/upload-logo'),
|
||||
data: { logo: file }
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar documento
|
||||
*/
|
||||
delete(documentId) {
|
||||
return {
|
||||
method: 'delete',
|
||||
url: apiURL(`documents/${documentId}`)
|
||||
};
|
||||
}
|
||||
};
|
||||
273
src/types/documents.d.ts
vendored
Normal file
273
src/types/documents.d.ts
vendored
Normal file
@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Tipos y interfaces para el sistema de documentos
|
||||
*
|
||||
* Este archivo define los contratos de datos entre frontend y backend
|
||||
* para el módulo de generación de documentos (Cotizaciones, Facturas, Remisiones)
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tipos de documentos soportados
|
||||
*/
|
||||
export type DocumentType = 'COTIZACION' | 'FACTURA' | 'REMISION';
|
||||
|
||||
/**
|
||||
* Estados posibles de un documento
|
||||
*/
|
||||
export type DocumentStatus = 'BORRADOR' | 'FINALIZADO' | 'ENVIADO' | 'CANCELADO';
|
||||
|
||||
/**
|
||||
* Régimen fiscal
|
||||
*/
|
||||
export type RegimenFiscal =
|
||||
| 'Régimen Simplificado de Confianza'
|
||||
| 'Personas Físicas con Actividades Empresariales y Profesionales';
|
||||
|
||||
/**
|
||||
* Tipo de comprobante (para facturas CFDI)
|
||||
*/
|
||||
export type TipoComprobante = 'Ingreso' | 'Egreso' | 'Traslado';
|
||||
|
||||
/**
|
||||
* Configuración de branding/tema del documento
|
||||
*/
|
||||
export interface DocumentTemplate {
|
||||
documentType?: DocumentType; // Para compatibilidad con código actual
|
||||
primaryColor: string;
|
||||
secondaryColor?: string;
|
||||
logoUrl?: string; // URL del logo almacenado en servidor
|
||||
logo?: string | File; // Temporal para upload (Base64 o File)
|
||||
logoPreview?: string; // Preview en frontend
|
||||
slogan?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos de la empresa emisora
|
||||
*/
|
||||
export interface EmpresaData {
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
email: string;
|
||||
telefono: string;
|
||||
direccion: string;
|
||||
web?: string;
|
||||
|
||||
// Específico para facturas (CFDI)
|
||||
lugar?: string; // Lugar de expedición (código postal)
|
||||
cfdi?: string; // Uso de CFDI (ej: "G03 - Gastos en general")
|
||||
regimen?: RegimenFiscal; // Régimen fiscal
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos bancarios (opcional, principalmente para cotizaciones)
|
||||
*/
|
||||
export interface BancosData {
|
||||
banco?: string;
|
||||
tipoCuenta?: string; // Ej: "Cuenta de cheques"
|
||||
cuenta?: string; // Número de cuenta
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos del cliente receptor
|
||||
*/
|
||||
export interface ClienteData {
|
||||
nombre: string;
|
||||
rfc?: string;
|
||||
domicilio?: string;
|
||||
telefono?: string;
|
||||
|
||||
// Específico para facturas (CFDI)
|
||||
regimen?: RegimenFiscal; // Régimen fiscal del cliente
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos del ejecutivo de ventas (solo cotizaciones)
|
||||
*/
|
||||
export interface EjecutivoData {
|
||||
nombre?: string;
|
||||
correo?: string;
|
||||
celular?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detalles específicos del documento
|
||||
*/
|
||||
export interface DocumentoDetalles {
|
||||
// Común para todos
|
||||
folio?: string;
|
||||
observaciones?: string;
|
||||
|
||||
// Específico para cotizaciones
|
||||
fechaRealizacion?: string;
|
||||
vigencia?: string;
|
||||
|
||||
// Específico para facturas
|
||||
serie?: string;
|
||||
fechaEmision?: string;
|
||||
tipoComprobante?: TipoComprobante;
|
||||
|
||||
// Específico para remisiones
|
||||
fechaRemision?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Producto en la tabla de productos
|
||||
*/
|
||||
export interface Producto {
|
||||
id?: number; // ID
|
||||
clave?: string; // Clave del producto/servicio
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
unidad?: string; // Unidad de medida (ej: "Pieza", "Servicio", "Hora")
|
||||
precioUnitario: number;
|
||||
descuento?: number; // Porcentaje de descuento
|
||||
iva?: number; // Porcentaje de IVA
|
||||
|
||||
// Calculados (pueden venir del frontend o backend)
|
||||
subtotal?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Totales calculados del documento
|
||||
*/
|
||||
export interface Totales {
|
||||
// Totales principales
|
||||
subtotal: number;
|
||||
iva: number;
|
||||
total: number;
|
||||
|
||||
// Totales adicionales (opcionales)
|
||||
subtotal1?: number; // Subtotal antes de descuento
|
||||
descuentoTotal?: number; // Total de descuentos aplicados
|
||||
subtotal2?: number; // Subtotal después de descuento
|
||||
impuestosTrasladados?: number; // Para facturas
|
||||
}
|
||||
|
||||
/**
|
||||
* Estructura completa de datos del documento en la BD
|
||||
*
|
||||
* Este es el formato JSON que se guarda en la base de datos
|
||||
* y el que el backend enviará/recibirá
|
||||
*/
|
||||
export interface DocumentRecord {
|
||||
// Identificadores
|
||||
id?: string | number;
|
||||
tipo: DocumentType;
|
||||
estado: DocumentStatus;
|
||||
folio: string;
|
||||
|
||||
// Configuración de template/branding
|
||||
templateConfig: DocumentTemplate;
|
||||
|
||||
// Datos agrupados del documento
|
||||
datos: {
|
||||
empresa: EmpresaData;
|
||||
bancos?: BancosData; // Solo para cotizaciones
|
||||
cliente: ClienteData;
|
||||
ejecutivo?: EjecutivoData; // Solo para cotizaciones
|
||||
documento: DocumentoDetalles;
|
||||
productos: Producto[];
|
||||
totales: Totales;
|
||||
};
|
||||
|
||||
// URLs de archivos generados
|
||||
pdfUrl?: string;
|
||||
logoUrl?: string;
|
||||
|
||||
// Metadatos
|
||||
userId?: string | number; // ID del usuario que creó el documento
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload para crear o actualizar un documento
|
||||
*
|
||||
* Este es el objeto que el frontend envía al backend
|
||||
*/
|
||||
export interface SaveDocumentPayload {
|
||||
tipo: DocumentType;
|
||||
estado?: DocumentStatus;
|
||||
templateConfig: DocumentTemplate;
|
||||
datos: DocumentRecord['datos'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Respuesta del backend al guardar un documento
|
||||
*/
|
||||
export interface SaveDocumentResponse {
|
||||
status: 'success' | 'fail' | 'error';
|
||||
data: DocumentRecord;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload para generar PDF
|
||||
*/
|
||||
export interface GeneratePDFPayload {
|
||||
documentId: string | number;
|
||||
options?: {
|
||||
format?: 'A4' | 'Letter';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Respuesta al generar PDF
|
||||
*/
|
||||
export interface GeneratePDFResponse {
|
||||
status: 'success' | 'fail' | 'error';
|
||||
data: {
|
||||
pdfUrl: string;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload para subir logo
|
||||
*/
|
||||
export interface UploadLogoPayload {
|
||||
logo: File;
|
||||
documentType?: DocumentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Respuesta al subir logo
|
||||
*/
|
||||
export interface UploadLogoResponse {
|
||||
status: 'success' | 'fail' | 'error';
|
||||
data: {
|
||||
logoUrl: string;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtros para listar documentos
|
||||
*/
|
||||
export interface DocumentFilters {
|
||||
tipo?: DocumentType;
|
||||
estado?: DocumentStatus;
|
||||
fechaInicio?: string;
|
||||
fechaFin?: string;
|
||||
search?: string; // Búsqueda por folio, cliente, etc.
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Respuesta de listado de documentos
|
||||
*/
|
||||
export interface DocumentListResponse {
|
||||
status: 'success' | 'fail' | 'error';
|
||||
data: {
|
||||
data: DocumentRecord[];
|
||||
pagination: {
|
||||
total: number;
|
||||
perPage: number;
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user