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:
Juan Felipe Zapata Moreno 2026-02-03 15:25:19 -06:00
parent b895836849
commit a45cc247c1
5 changed files with 884 additions and 219 deletions

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

View File

@ -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">
<p class="text-xs text-green-600 dark:text-green-400">
Este cliente tiene {{ request.sales.length }} venta(s) registrada(s)
</p>
<!-- 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">
<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" />
<div>
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Fecha de Registro
</label>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formatDate(request.created_at) }}
</p>
<!-- 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-blue-600 dark:text-blue-400 uppercase mb-1">
Nombre
</label>
<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>
<!-- 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 -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<!-- 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>

View File

@ -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;
showDetailModal.value = true;
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>
@ -76,41 +209,49 @@ onMounted(() => {
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-medium text-gray-900 dark:text-gray-100">
{{ request.name }}
</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' }}
<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.client?.name || 'N/A' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ 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">
<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">
{{ request.regimen_fiscal || 'N/A' }}
{{ formatDate(request.requested_at) }}
</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ formatDate(request.created_at) }}
</p>
</td>
<td class="px-6 py-4 text-center">
<div class="flex items-center justify-center space-x-2">
<button
@ -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">

View File

@ -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
@ -412,5 +570,4 @@ onMounted(() => {
</div>
</div>
</div>
</template>
</template>

View File

@ -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)}`), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
// 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();