feat: mejorar gestión de facturación y búsqueda por scanner
- Agrega estadísticas detalladas y manejo de carga en BillingRequests.vue. - Actualiza tablas para mostrar historial, montos y estados con badges. - Previene solicitudes duplicadas hasta que la anterior sea procesada. - Implementa hook useBarcodeScanner en Point.vue para búsqueda por código/serie
This commit is contained in:
parent
b895836849
commit
a45cc247c1
45
src/composables/useBarcodeScanner.js
Normal file
45
src/composables/useBarcodeScanner.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
export function useBarcodeScanner(options = {}) {
|
||||
const {
|
||||
onScan,
|
||||
minLength = 3,
|
||||
scanTimeout = 100,
|
||||
enterKey = true
|
||||
} = options;
|
||||
|
||||
let barcode = '';
|
||||
let timeout;
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
// Ignorar si está escribiendo en inputs
|
||||
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && enterKey) {
|
||||
if (barcode.length >= minLength) {
|
||||
onScan?.(barcode);
|
||||
barcode = '';
|
||||
}
|
||||
} else if (e.key.length === 1) {
|
||||
clearTimeout(timeout);
|
||||
barcode += e.key;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
barcode = '';
|
||||
}, scanTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keypress', handleKeyPress);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keypress', handleKeyPress);
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useForm, apiURL } from '@Services/Api';
|
||||
import { formatCurrency, formatDate } from '@/utils/formatters';
|
||||
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import Input from '@Holos/Form/Input.vue';
|
||||
|
||||
/** Props */
|
||||
const props = defineProps({
|
||||
@ -18,19 +20,115 @@ const emit = defineEmits(['close', 'refresh']);
|
||||
|
||||
/** Estado */
|
||||
const processing = ref(false);
|
||||
const showProcessModal = ref(false);
|
||||
const showRejectModal = ref(false);
|
||||
|
||||
const processForm = useForm({
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const rejectForm = useForm({
|
||||
notes: ''
|
||||
});
|
||||
|
||||
/** Computed */
|
||||
const hasSales = computed(() => props.request.sales && props.request.sales.length > 0);
|
||||
const latestSale = computed(() => hasSales.value ? props.request.sales[0] : null);
|
||||
const isPending = computed(() => props.request.status === 'pending');
|
||||
|
||||
const statusBadge = computed(() => {
|
||||
const badges = {
|
||||
pending: {
|
||||
class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 border-yellow-300 dark:border-yellow-700',
|
||||
icon: 'schedule',
|
||||
label: 'Pendiente'
|
||||
},
|
||||
processed: {
|
||||
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 border-green-300 dark:border-green-700',
|
||||
icon: 'check_circle',
|
||||
label: 'Procesada'
|
||||
},
|
||||
rejected: {
|
||||
class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-300 dark:border-red-700',
|
||||
icon: 'cancel',
|
||||
label: 'Rechazada'
|
||||
}
|
||||
};
|
||||
return badges[props.request.status] || badges.pending;
|
||||
});
|
||||
|
||||
const paymentMethods = [
|
||||
{
|
||||
value: 'cash',
|
||||
label: 'Efectivo',
|
||||
},
|
||||
{
|
||||
value: 'credit_card',
|
||||
label: 'Tarjeta de Crédito',
|
||||
},
|
||||
{
|
||||
value: 'debit_card',
|
||||
label: 'Tarjeta de Débito',
|
||||
}
|
||||
];
|
||||
|
||||
const paymentMethodLabel = computed(() => {
|
||||
const method = paymentMethods.find(m => props.request.sale?.payment_method?.includes(m.value));
|
||||
return method?.label || props.request.sale?.payment_method || 'N/A';
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const closeModal = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const openProcessModal = () => {
|
||||
processForm.reset();
|
||||
showProcessModal.value = true;
|
||||
};
|
||||
|
||||
const openRejectModal = () => {
|
||||
rejectForm.reset();
|
||||
showRejectModal.value = true;
|
||||
};
|
||||
|
||||
const submitProcess = () => {
|
||||
processing.value = true;
|
||||
processForm.put(apiURL(`invoice-requests/${props.request.id}/process`), {
|
||||
onSuccess: () => {
|
||||
window.Notify.success('Solicitud marcada como procesada correctamente');
|
||||
showProcessModal.value = false;
|
||||
emit('refresh');
|
||||
emit('close');
|
||||
},
|
||||
onError: () => {
|
||||
window.Notify.error('Error al procesar la solicitud');
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submitReject = () => {
|
||||
processing.value = true;
|
||||
rejectForm.put(apiURL(`invoice-requests/${props.request.id}/reject`), {
|
||||
onSuccess: () => {
|
||||
window.Notify.success('Solicitud rechazada correctamente');
|
||||
showRejectModal.value = false;
|
||||
emit('refresh');
|
||||
emit('close');
|
||||
},
|
||||
onError: () => {
|
||||
window.Notify.error('Error al rechazar la solicitud');
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="true" max-width="4xl" @close="closeModal">
|
||||
<Modal :show="true" max-width="5xl" @close="closeModal">
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
@ -40,13 +138,10 @@ const closeModal = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Datos de Facturación
|
||||
Solicitud de Facturación #{{ request.id }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" v-if="hasSales">
|
||||
Última venta: {{ latestSale.invoice_number || `#${String(latestSale.id).padStart(6, '0')}` }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" v-else>
|
||||
Cliente con datos fiscales registrados
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Folio: {{ request.sale?.invoice_number || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -60,123 +155,75 @@ const closeModal = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Estado Badge -->
|
||||
<div class="mb-6">
|
||||
<div :class="['inline-flex items-center gap-2 px-4 py-2 rounded-lg border-2', statusBadge.class]">
|
||||
<GoogleIcon :name="statusBadge.icon" class="text-xl" />
|
||||
<span class="font-semibold">{{ statusBadge.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="space-y-6">
|
||||
<!-- Información del Cliente -->
|
||||
<div class="space-y-6 max-h-[70vh] overflow-y-auto">
|
||||
<!-- Información del Estado -->
|
||||
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<GoogleIcon name="person" class="text-xl text-gray-600 dark:text-gray-400" />
|
||||
<GoogleIcon name="info" class="text-xl text-gray-600 dark:text-gray-400" />
|
||||
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Datos del Cliente
|
||||
</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
|
||||
Nombre
|
||||
</label>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ request.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ request.email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
|
||||
Teléfono
|
||||
</label>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ request.phone || 'No especificado' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
|
||||
Dirección
|
||||
</label>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ request.address || 'No especificada' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información Fiscal -->
|
||||
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-5 border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<GoogleIcon name="account_balance" class="text-xl text-blue-600 dark:text-blue-400" />
|
||||
<h4 class="text-sm font-bold text-blue-700 dark:text-blue-300 uppercase tracking-wide">
|
||||
Datos Fiscales
|
||||
</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">
|
||||
RFC
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-mono font-bold text-blue-900 dark:text-blue-100">
|
||||
{{ request.rfc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">
|
||||
Razón Social
|
||||
</label>
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{{ request.razon_social || 'No especificada' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">
|
||||
Régimen Fiscal
|
||||
</label>
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{{ request.regimen_fiscal || 'No especificado' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">
|
||||
Código Postal Fiscal
|
||||
</label>
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{{ request.cp_fiscal || 'No especificado' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">
|
||||
Uso de CFDI
|
||||
</label>
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{{ request.uso_cfdi || 'No especificado' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información de la Venta -->
|
||||
<div v-if="hasSales" class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-xl p-5 border border-green-200 dark:border-green-800">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<GoogleIcon name="shopping_cart" class="text-xl text-green-600 dark:text-green-400" />
|
||||
<h4 class="text-sm font-bold text-green-700 dark:text-green-300 uppercase tracking-wide">
|
||||
Última Venta Registrada
|
||||
Estado de la Solicitud
|
||||
</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
|
||||
Fecha Solicitada
|
||||
</label>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatDate(request.requested_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="request.processed_at">
|
||||
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
|
||||
Fecha Procesada
|
||||
</label>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatDate(request.processed_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="request.processed_by_user">
|
||||
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
|
||||
Procesada Por
|
||||
</label>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ request.processed_by_user.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="request.notes" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
|
||||
Notas
|
||||
</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 italic">
|
||||
{{ request.notes }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información de la Venta -->
|
||||
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-xl p-5 border border-green-200 dark:border-green-800">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<GoogleIcon name="shopping_cart" class="text-xl text-green-600 dark:text-green-400" />
|
||||
<h4 class="text-sm font-bold text-green-700 dark:text-green-300 uppercase tracking-wide">
|
||||
Información de la Venta
|
||||
</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-green-600 dark:text-green-400 uppercase mb-1">
|
||||
Folio
|
||||
</label>
|
||||
<p class="text-sm font-bold text-green-900 dark:text-green-100">
|
||||
{{ latestSale.invoice_number || `#${String(latestSale.id).padStart(6, '0')}` }}
|
||||
<p class="text-sm font-bold font-mono text-green-900 dark:text-green-100">
|
||||
{{ request.sale?.invoice_number }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@ -184,41 +231,144 @@ const closeModal = () => {
|
||||
Total
|
||||
</label>
|
||||
<p class="text-lg font-bold text-green-600 dark:text-green-400">
|
||||
{{ formatCurrency(latestSale.total || 0) }}
|
||||
{{ formatCurrency(request.sale?.total) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-green-600 dark:text-green-400 uppercase mb-1">
|
||||
Fecha de Venta
|
||||
Método de Pago
|
||||
</label>
|
||||
<p class="text-sm font-medium text-green-900 dark:text-green-100">
|
||||
{{ formatDate(latestSale.created_at) }}
|
||||
<p class="text-sm font-medium text-green-900 dark:text-green-100 capitalize">
|
||||
{{ paymentMethodLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-green-200 dark:border-green-700" v-if="request.sales.length > 1">
|
||||
|
||||
<!-- Detalle de productos -->
|
||||
<div v-if="request.sale?.details && request.sale.details.length > 0" class="mt-4 pt-4 border-t border-green-200 dark:border-green-700">
|
||||
<label class="block text-xs font-semibold text-green-600 dark:text-green-400 uppercase mb-3">
|
||||
Productos Vendidos
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="item in request.sale.details"
|
||||
:key="item.id"
|
||||
class="p-3 bg-white/50 dark:bg-gray-800/50 rounded-lg"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-green-900 dark:text-green-100">
|
||||
{{ item.product_name }}
|
||||
</p>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
SKU: {{ item.inventory?.sku || 'N/A' }} • {{ item.inventory?.category?.name || 'Sin categoría' }}
|
||||
</p>
|
||||
<div v-if="item.serials && item.serials.length > 0" class="mt-1">
|
||||
<p class="text-xs text-green-600 dark:text-green-400">
|
||||
Este cliente tiene {{ request.sales.length }} venta(s) registrada(s)
|
||||
<span class="font-semibold">Series:</span>
|
||||
<span v-for="(serial, idx) in item.serials" :key="idx" class="ml-1 font-mono">
|
||||
{{ serial.serial_number }}{{ idx < item.serials.length - 1 ? ',' : '' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-4">
|
||||
<p class="text-sm font-bold text-green-900 dark:text-green-100">
|
||||
{{ formatCurrency(item.subtotal) }}
|
||||
</p>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
{{ item.quantity }} × {{ formatCurrency(item.unit_price) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fecha de Registro -->
|
||||
<div class="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<GoogleIcon name="schedule" class="text-2xl text-gray-600 dark:text-gray-400" />
|
||||
<!-- Información del Cliente -->
|
||||
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-5 border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<GoogleIcon name="person" class="text-xl text-blue-600 dark:text-blue-400" />
|
||||
<h4 class="text-sm font-bold text-blue-700 dark:text-blue-300 uppercase tracking-wide">
|
||||
Datos del Cliente
|
||||
</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||
Fecha de Registro
|
||||
<label class="block text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">
|
||||
Nombre
|
||||
</label>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatDate(request.created_at) }}
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{{ request.client?.name || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">
|
||||
Email
|
||||
</label>
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{{ request.client?.email || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Información Fiscal -->
|
||||
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-xl p-5 border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<GoogleIcon name="account_balance" class="text-xl text-purple-600 dark:text-purple-400" />
|
||||
<h4 class="text-sm font-bold text-purple-700 dark:text-purple-300 uppercase tracking-wide">
|
||||
Datos Fiscales
|
||||
</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
|
||||
RFC
|
||||
</label>
|
||||
<p class="text-sm font-mono font-bold text-purple-900 dark:text-purple-100">
|
||||
{{ request.client?.rfc || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
|
||||
Razón Social
|
||||
</label>
|
||||
<p class="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
{{ request.client?.razon_social || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
|
||||
Régimen Fiscal
|
||||
</label>
|
||||
<p class="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
{{ request.client?.regimen_fiscal || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
|
||||
C.P. Fiscal
|
||||
</label>
|
||||
<p class="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
{{ request.client?.cp_fiscal || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
|
||||
Uso de CFDI
|
||||
</label>
|
||||
<p class="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
{{ request.client?.uso_cfdi || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer con acciones -->
|
||||
<div class="flex items-center justify-between gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
@ -227,8 +377,128 @@ const closeModal = () => {
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
|
||||
<!-- Acciones solo para solicitudes pendientes -->
|
||||
<div v-if="isPending" class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="openRejectModal"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-red-600 hover:bg-red-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<GoogleIcon name="cancel" class="text-lg" />
|
||||
Rechazar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openProcessModal"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-green-600 hover:bg-green-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<GoogleIcon name="check_circle" class="text-lg" />
|
||||
Marcar como Procesada
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<!-- Modal para procesar -->
|
||||
<Modal :show="showProcessModal" max-width="md" @close="showProcessModal = false">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<GoogleIcon name="check_circle" class="text-2xl text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Marcar como Procesada
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Solicitud #{{ request.id }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitProcess" class="space-y-4">
|
||||
<Input
|
||||
v-model="processForm.notes"
|
||||
id="process_notes"
|
||||
title="Notas (opcional)"
|
||||
placeholder="Ej: Factura timbrada. UUID: 123456-ABCD-7890"
|
||||
type="textarea"
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showProcessModal = false"
|
||||
:disabled="processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<GoogleIcon name="check" class="text-lg" />
|
||||
{{ processing ? 'Procesando...' : 'Confirmar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal para rechazar -->
|
||||
<Modal :show="showRejectModal" max-width="md" @close="showRejectModal = false">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<GoogleIcon name="cancel" class="text-2xl text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Rechazar Solicitud
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Solicitud #{{ request.id }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitReject" class="space-y-4">
|
||||
<Input
|
||||
v-model="rejectForm.notes"
|
||||
id="reject_notes"
|
||||
title="Motivo del rechazo *"
|
||||
placeholder="Ej: RFC inválido, no coincide con constancia fiscal del SAT"
|
||||
type="textarea"
|
||||
rows="3"
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showRejectModal = false"
|
||||
:disabled="processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<GoogleIcon name="cancel" class="text-lg" />
|
||||
{{ processing ? 'Procesando...' : 'Rechazar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useSearcher, apiURL } from '@Services/Api';
|
||||
import { formatDate } from '@/utils/formatters';
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { useApi, useSearcher, apiURL } from '@Services/Api';
|
||||
import { formatDate, formatCurrency } from '@/utils/formatters';
|
||||
|
||||
import SearcherHead from '@Holos/Searcher.vue';
|
||||
import Table from '@Holos/Table.vue';
|
||||
@ -9,26 +9,81 @@ import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import BillingRequestDetailModal from './BillingRequestDetailModal.vue';
|
||||
|
||||
/** Estado */
|
||||
const billingRequests = ref([]);
|
||||
const billingRequests = ref({});
|
||||
const stats = ref({
|
||||
pending: 0,
|
||||
processed: 0,
|
||||
rejected: 0,
|
||||
total: 0,
|
||||
today_pending: 0,
|
||||
this_month: 0
|
||||
});
|
||||
const selectedStatus = ref('pending');
|
||||
const showDetailModal = ref(false);
|
||||
const selectedRequest = ref(null);
|
||||
const loadingStats = ref(false);
|
||||
|
||||
const api = useApi();
|
||||
|
||||
/** Computed */
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: 'Todas', icon: 'list', color: 'gray' },
|
||||
{ value: 'pending', label: 'Pendientes', icon: 'schedule', color: 'yellow' },
|
||||
{ value: 'processed', label: 'Procesadas', icon: 'check_circle', color: 'green' },
|
||||
{ value: 'rejected', label: 'Rechazadas', icon: 'cancel', color: 'red' }
|
||||
];
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const badges = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
processed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
rejected: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
||||
};
|
||||
return badges[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
|
||||
};
|
||||
|
||||
/** Métodos */
|
||||
const searcher = useSearcher({
|
||||
url: apiURL('clients?with=sales'),
|
||||
url: apiURL('invoice-requests'),
|
||||
filters: computed(() => ({
|
||||
status: selectedStatus.value === 'all' ? undefined : selectedStatus.value
|
||||
})),
|
||||
onSuccess: (r) => {
|
||||
billingRequests.value = r.clients || r.data || [];
|
||||
// El componente Table espera el objeto de paginación completo, no solo el array
|
||||
billingRequests.value = r.invoice_requests || {};
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ ERROR al cargar solicitudes de facturación:', error);
|
||||
billingRequests.value = [];
|
||||
billingRequests.value = {};
|
||||
window.Notify.error('Error al cargar solicitudes de facturación');
|
||||
}
|
||||
});
|
||||
|
||||
const openDetailModal = (request) => {
|
||||
selectedRequest.value = request;
|
||||
const fetchStats = () => {
|
||||
loadingStats.value = true;
|
||||
api.get(apiURL('invoice-requests/stats'), {
|
||||
onSuccess: (data) => {
|
||||
stats.value = data.stats || {};
|
||||
},
|
||||
onError: () => {
|
||||
window.Notify.error('Error al cargar estadísticas');
|
||||
},
|
||||
onFinish: () => {
|
||||
loadingStats.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openDetailModal = async (request) => {
|
||||
// Cargar detalles completos
|
||||
api.get(apiURL(`invoice-requests/${request.id}`), {
|
||||
onSuccess: (data) => {
|
||||
selectedRequest.value = data.invoice_request;
|
||||
showDetailModal.value = true;
|
||||
},
|
||||
onError: () => {
|
||||
window.Notify.error('Error al cargar detalles de la solicitud');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const closeDetailModal = () => {
|
||||
@ -38,16 +93,93 @@ const closeDetailModal = () => {
|
||||
|
||||
const refreshList = () => {
|
||||
searcher.search();
|
||||
fetchStats();
|
||||
};
|
||||
|
||||
/** Ciclo de vida */
|
||||
onMounted(() => {
|
||||
fetchStats();
|
||||
searcher.search();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Estadísticas -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Total</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ stats.total }}
|
||||
</p>
|
||||
</div>
|
||||
<GoogleIcon name="receipt_long" class="text-3xl text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Pendientes</p>
|
||||
<p class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ stats.pending }}
|
||||
</p>
|
||||
</div>
|
||||
<GoogleIcon name="schedule" class="text-3xl text-yellow-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Procesadas</p>
|
||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{{ stats.processed }}
|
||||
</p>
|
||||
</div>
|
||||
<GoogleIcon name="check_circle" class="text-3xl text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Rechazadas</p>
|
||||
<p class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{{ stats.rejected }}
|
||||
</p>
|
||||
</div>
|
||||
<GoogleIcon name="cancel" class="text-3xl text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Hoy</p>
|
||||
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ stats.today_pending }}
|
||||
</p>
|
||||
</div>
|
||||
<GoogleIcon name="today" class="text-3xl text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Este mes</p>
|
||||
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{{ stats.this_month }}
|
||||
</p>
|
||||
</div>
|
||||
<GoogleIcon name="calendar_month" class="text-3xl text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearcherHead
|
||||
:title="'Solicitudes de Facturación'"
|
||||
placeholder="Buscar por folio, cliente o RFC..."
|
||||
@ -61,11 +193,12 @@ onMounted(() => {
|
||||
@send-pagination="(page) => searcher.pagination(page)"
|
||||
>
|
||||
<template #head>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FOLIO</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CLIENTE</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RFC</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RAZÓN SOCIAL</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RÉGIMEN FISCAL</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA REGISTRO</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TOTAL</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA SOLICITUD</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ACCIONES</th>
|
||||
</template>
|
||||
|
||||
@ -75,39 +208,47 @@ onMounted(() => {
|
||||
:key="request.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<td class="px-6 py-4 text-left">
|
||||
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ request.sale?.invoice_number || 'N/A' }}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-left">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ request.name }}
|
||||
{{ request.client?.name || 'N/A' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ request.email }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ request.phone || 'N/A' }}
|
||||
{{ request.client?.email || 'N/A' }}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-left">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 font-mono">
|
||||
{{ request.rfc || 'N/A' }}
|
||||
{{ request.client?.rfc || 'N/A' }}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-left">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ request.razon_social || 'N/A' }}
|
||||
<td class="px-6 py-4 text-right">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatCurrency(request.sale?.total) }}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-center">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ request.regimen_fiscal || 'N/A' }}
|
||||
</p>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
||||
getStatusBadge(request.status)
|
||||
]"
|
||||
>
|
||||
{{ statusOptions.find(o => o.value === request.status)?.label }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-center">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatDate(request.created_at) }}
|
||||
{{ formatDate(request.requested_at) }}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
@ -127,10 +268,10 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<td colspan="6" class="table-cell text-center">
|
||||
<td colspan="7" class="table-cell text-center">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<GoogleIcon
|
||||
name="receipt"
|
||||
name="receipt_long"
|
||||
class="text-6xl mb-3 opacity-50"
|
||||
/>
|
||||
<p class="font-semibold text-lg mb-1">
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { apiURL } from '@Services/Api';
|
||||
import { formatDate, formatCurrency } from '@/utils/formatters';
|
||||
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import Loader from '@Shared/Loader.vue';
|
||||
@ -18,6 +19,7 @@ const submitted = ref(false);
|
||||
const error = ref(null);
|
||||
const saleData = ref(null);
|
||||
const clientData = ref(null);
|
||||
const existingInvoiceRequest = ref(null);
|
||||
const formErrors = ref({});
|
||||
|
||||
const form = ref({
|
||||
@ -32,10 +34,46 @@ const form = ref({
|
||||
uso_cfdi: ''
|
||||
});
|
||||
|
||||
const paymentMethods = [
|
||||
{
|
||||
value: 'cash',
|
||||
label: 'Efectivo',
|
||||
},
|
||||
{
|
||||
value: 'credit_card',
|
||||
label: 'Tarjeta de Crédito',
|
||||
},
|
||||
{
|
||||
value: 'debit_card',
|
||||
label: 'Tarjeta de Débito',
|
||||
}
|
||||
];
|
||||
|
||||
const paymentMethodLabel = computed(() => {
|
||||
const method = paymentMethods.find(m => saleData.value?.payment_method?.includes(m.value));
|
||||
return method?.label || saleData.value?.payment_method || 'N/A';
|
||||
});
|
||||
|
||||
/** Computed */
|
||||
const invoiceNumber = computed(() => route.params.invoiceNumber);
|
||||
const hasClient = computed(() => !!clientData.value);
|
||||
|
||||
const hasExistingRequest = computed(() => {
|
||||
if (!saleData.value?.invoice_requests) return false;
|
||||
return saleData.value.invoice_requests.length > 0;
|
||||
});
|
||||
|
||||
const latestRequest = computed(() => {
|
||||
if (!hasExistingRequest.value) return null;
|
||||
return saleData.value.invoice_requests[0];
|
||||
});
|
||||
|
||||
const canRequestInvoice = computed(() => {
|
||||
if (!latestRequest.value) return true;
|
||||
// Solo permitir nueva solicitud si la última fue rechazada
|
||||
return latestRequest.value.status === 'rejected';
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const fetchSaleData = async () => {
|
||||
loading.value = true;
|
||||
@ -49,24 +87,21 @@ const fetchSaleData = async () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
error.value = 'No se encontró la venta con el folio proporcionado.';
|
||||
} else if (response.status === 400) {
|
||||
const data = await response.json();
|
||||
error.value = data.message || 'Esta venta ya fue facturada.';
|
||||
// Venta ya tiene solicitud de facturación pendiente/procesada
|
||||
error.value = result.message || 'Esta venta ya tiene una solicitud de facturación.';
|
||||
existingInvoiceRequest.value = result.data?.invoice_request || null;
|
||||
} else {
|
||||
error.value = 'Error al obtener los datos de la venta.';
|
||||
error.value = result.message || 'Error al obtener los datos de la venta.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
error.value = 'No se encontraron datos para esta venta.';
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
saleData.value = result.data.sale;
|
||||
clientData.value = result.data.client || null;
|
||||
|
||||
@ -78,7 +113,6 @@ const fetchSaleData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const submitForm = async () => {
|
||||
submitting.value = true;
|
||||
formErrors.value = {};
|
||||
@ -133,13 +167,6 @@ const submitForm = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(value || 0);
|
||||
};
|
||||
|
||||
/** Ciclos */
|
||||
onMounted(() => {
|
||||
fetchSaleData();
|
||||
@ -148,7 +175,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 py-8 px-4">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary dark:bg-primary-d mb-4">
|
||||
@ -167,7 +194,7 @@ onMounted(() => {
|
||||
<Loader />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<!-- Error con detalles de solicitud existente -->
|
||||
<div v-else-if="error" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 md:p-8 text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
|
||||
<GoogleIcon name="error" class="text-3xl text-red-500" />
|
||||
@ -175,9 +202,35 @@ onMounted(() => {
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No se puede procesar
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<!-- Información de solicitud existente -->
|
||||
<div v-if="existingInvoiceRequest" class="mt-6 p-4 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50">
|
||||
<h3 class="text-sm font-semibold mb-3 text-gray-800 dark:text-gray-200">
|
||||
Detalles de la solicitud:
|
||||
</h3>
|
||||
<div class="text-sm space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<span class="font-medium">Estado:</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold">
|
||||
{{ existingInvoiceRequest.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
<span class="font-medium">Solicitada:</span>
|
||||
<span class="ml-1">{{ formatDate(existingInvoiceRequest.requested_at) }}</span>
|
||||
</p>
|
||||
<p v-if="existingInvoiceRequest.processed_at">
|
||||
<span class="font-medium">Procesada:</span>
|
||||
<span class="ml-1">{{ formatDate(existingInvoiceRequest.processed_at) }}</span>
|
||||
</p>
|
||||
<p v-if="existingInvoiceRequest.notes" class="mt-2 pt-2 border-t border-gray-300 dark:border-gray-600 italic">
|
||||
{{ existingInvoiceRequest.notes }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submitted Success -->
|
||||
@ -189,7 +242,7 @@ onMounted(() => {
|
||||
Solicitud Enviada
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Sus datos han sido recibidos correctamente. Recibirá su factura en el correo electrónico proporcionado.
|
||||
Sus datos han sido recibidos correctamente. Su solicitud será procesada a la brevedad y recibirá su factura en el correo electrónico proporcionado.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">
|
||||
Folio: <span class="font-mono font-semibold">{{ invoiceNumber }}</span>
|
||||
@ -207,18 +260,22 @@ onMounted(() => {
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Folio:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ saleData.invoice_number }}</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white font-mono">{{ saleData.invoice_number }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Fecha:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">
|
||||
{{ new Date(saleData.created_at).toLocaleDateString('es-MX') }}
|
||||
{{ new Date(saleData.created_at).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}) }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Método de pago:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white capitalize">
|
||||
{{ saleData.payment_method?.replace('_', ' ') }}
|
||||
{{ paymentMethodLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@ -228,10 +285,111 @@ onMounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items de la venta -->
|
||||
<div v-if="saleData.items && saleData.items.length > 0" class="mt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Artículos Comprados
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div v-for="item in saleData.items" :key="item.id"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ item.product_name }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ item.category }} • SKU: {{ item.sku }}
|
||||
</p>
|
||||
<div v-if="item.serial_numbers && item.serial_numbers.length > 0"
|
||||
class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">Serie(s):</span>
|
||||
<span v-for="(serial, idx) in item.serial_numbers" :key="idx"
|
||||
class="ml-1 font-mono">
|
||||
{{ serial.serial_number }}{{ idx < item.serial_numbers.length - 1 ? ',' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-4">
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatCurrency(item.subtotal) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ item.quantity }} × {{ formatCurrency(item.unit_price) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historial de solicitudes (si existen) -->
|
||||
<div v-if="hasExistingRequest" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<GoogleIcon name="history" class="text-xl" />
|
||||
Historial de Solicitudes
|
||||
</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="request in saleData.invoice_requests" :key="request.id"
|
||||
class="p-4 border rounded-lg border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
Solicitud #{{ request.id }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200">
|
||||
{{ request.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs space-y-1 text-gray-700 dark:text-gray-300">
|
||||
<p class="flex items-center gap-2">
|
||||
<GoogleIcon name="event" class="text-sm" />
|
||||
<span class="font-medium">Solicitada:</span>
|
||||
<span>{{ formatDate(request.requested_at) }}</span>
|
||||
</p>
|
||||
<p v-if="request.processed_at" class="flex items-center gap-2">
|
||||
<GoogleIcon name="check" class="text-sm" />
|
||||
<span class="font-medium">Procesada:</span>
|
||||
<span>{{ formatDate(request.processed_at) }}</span>
|
||||
</p>
|
||||
<p v-if="request.notes" class="mt-2 pt-2 border-t border-gray-300 dark:border-gray-600 italic">
|
||||
<GoogleIcon name="note" class="text-sm inline mr-1" />
|
||||
{{ request.notes }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-blue-800 dark:text-blue-200">
|
||||
<GoogleIcon name="info" class="inline mr-1" />
|
||||
<span v-if="latestRequest?.status === 'pending'">
|
||||
Su solicitud está siendo procesada. Recibirá su factura por correo electrónico.
|
||||
</span>
|
||||
<span v-else-if="latestRequest?.status === 'processed'">
|
||||
Su factura ya fue emitida y enviada. Revise su correo electrónico.
|
||||
</span>
|
||||
<span v-else-if="latestRequest?.status === 'rejected'">
|
||||
Su solicitud fue rechazada. Puede enviar una nueva solicitud corrigiendo los datos.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje si no puede solicitar factura -->
|
||||
<div v-if="!canRequestInvoice && !submitted"
|
||||
class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-lg p-6 text-center">
|
||||
<GoogleIcon name="info" class="text-3xl text-yellow-600 dark:text-yellow-400 mb-2" />
|
||||
<p class="text-yellow-800 dark:text-yellow-200 font-medium">
|
||||
Ya existe una solicitud de factura en proceso para esta venta.
|
||||
</p>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-2">
|
||||
No es posible crear una nueva solicitud hasta que la actual sea procesada o rechazada.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cliente asociado - Solo confirmación -->
|
||||
<div v-if="hasClient" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<div v-else-if="hasClient && canRequestInvoice" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<GoogleIcon name="person" class="text-xl" />
|
||||
Datos del Cliente
|
||||
@ -291,7 +449,7 @@ onMounted(() => {
|
||||
Procesando...
|
||||
</template>
|
||||
<template v-else>
|
||||
<GoogleIcon name="check_circle" class="mr-2" />
|
||||
<GoogleIcon name="send" class="mr-2" />
|
||||
Confirmar y Solicitar Factura
|
||||
</template>
|
||||
</PrimaryButton>
|
||||
@ -303,7 +461,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Sin cliente - Formulario manual -->
|
||||
<form v-else @submit.prevent="submitForm" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<form v-else-if="canRequestInvoice" @submit.prevent="submitForm" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<GoogleIcon name="description" class="text-xl" />
|
||||
Datos de Facturación
|
||||
@ -413,4 +571,3 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useSearcher, apiURL } from '@Services/Api';
|
||||
import { page } from '@Services/Page';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import { useBarcodeScanner } from '@/composables/useBarcodeScanner';
|
||||
import useCart from '@Stores/cart';
|
||||
import salesService from '@Services/salesService';
|
||||
import ticketService from '@Services/ticketService';
|
||||
@ -142,31 +143,73 @@ const handleCodeDetected = async (barcode) => {
|
||||
try {
|
||||
window.Notify.info('Buscando producto...');
|
||||
|
||||
// Buscar producto por código de barras
|
||||
const response = await fetch(apiURL(`inventario?q=${encodeURIComponent(barcode)}`), {
|
||||
// Intentar buscar como código de barras del producto
|
||||
const productResponse = await fetch(
|
||||
apiURL(`inventario?q=${encodeURIComponent(barcode)}`),
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
const productResult = await productResponse.json();
|
||||
|
||||
// Verificar si se encontró el producto
|
||||
if (result.data && result.data.products && result.data.products.data && result.data.products.data.length > 0) {
|
||||
const product = result.data.products.data[0];
|
||||
if (productResult.data?.products?.data?.length > 0) {
|
||||
const product = productResult.data.products.data[0];
|
||||
|
||||
// Verificar si el producto tiene stock
|
||||
if (product.stock <= 0) {
|
||||
window.Notify.error(`${product.name} no tiene stock disponible`);
|
||||
window.Notify.warning(`El producto "${product.name}" no tiene stock disponible`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Agregar producto al carrito
|
||||
addToCart(product);
|
||||
} else {
|
||||
window.Notify.error('Producto no encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
// Si no se encontró como producto, buscar como número de serie
|
||||
window.Notify.info('Buscando por número de serie...');
|
||||
|
||||
const serialResponse = await fetch(
|
||||
apiURL(`serials/search?serial_number=${encodeURIComponent(barcode)}`),
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const serialResult = await serialResponse.json();
|
||||
|
||||
if (serialResult.data?.serial) {
|
||||
const serial = serialResult.data.serial;
|
||||
const product = serial.inventory;
|
||||
|
||||
if (!product) {
|
||||
window.Notify.error('Producto del serial no encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
if (serial.status !== 'disponible') {
|
||||
window.Notify.error(`Serial ${barcode} no está disponible (${serial.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Agregar producto con ese serial específico
|
||||
product.track_serials = true;
|
||||
cart.addProductWithSerials(product, 1, {
|
||||
serialNumbers: [serial.serial_number],
|
||||
selectionMode: 'manual'
|
||||
});
|
||||
|
||||
window.Notify.success(`${product.name} (SN: ${barcode}) agregado al carrito`);
|
||||
return;
|
||||
}
|
||||
|
||||
window.Notify.error('Producto o serial no encontrado');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error buscando producto:', error);
|
||||
window.Notify.error('Error al buscar el producto');
|
||||
@ -314,6 +357,15 @@ const handleOpenSerialSelector = (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
useBarcodeScanner({
|
||||
onScan: (barcode) => {
|
||||
// Reutilizar la misma lógica que ya tienes
|
||||
handleCodeDetected(barcode);
|
||||
},
|
||||
minLength: 5, // Ajusta según tus códigos de barras
|
||||
scanTimeout: 100
|
||||
});
|
||||
|
||||
/** Ciclo de vida */
|
||||
onMounted(() => {
|
||||
searcher.search();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user