feat: unidades de medida, validación de series y WhatsApp
- Integra selección de unidad y restringe series en cantidades decimales. - Implementa servicio de mensajería y facturación por WhatsApp. - Agrega componentes de sidebar y actualiza vistas de inventario.
This commit is contained in:
parent
cbf8ccb64c
commit
99f190f61b
70
src/components/Holos/Skeleton/Sidebar/DropdownMenu.vue
Normal file
70
src/components/Holos/Skeleton/Sidebar/DropdownMenu.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
/** Propiedades */
|
||||
const props = defineProps({
|
||||
icon: String,
|
||||
name: String
|
||||
});
|
||||
|
||||
/** Estado */
|
||||
const isOpen = ref(false);
|
||||
const vroute = useRoute();
|
||||
|
||||
/** Métodos */
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
/** Computed */
|
||||
const buttonClasses = computed(() => {
|
||||
const baseClasses = 'flex items-center justify-between h-11 w-full focus:outline-hidden hover:bg-gray-100 dark:hover:bg-gray-800 border-l-4 pr-6 transition text-left';
|
||||
const colorClasses = isOpen.value
|
||||
? 'bg-indigo-50 dark:bg-indigo-900/20 border-indigo-600 dark:border-indigo-400 text-indigo-700 dark:text-indigo-300'
|
||||
: 'border-transparent text-gray-700 dark:text-gray-300';
|
||||
|
||||
return `${baseClasses} ${colorClasses}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<button
|
||||
@click="toggle"
|
||||
:class="buttonClasses"
|
||||
type="button"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
v-if="icon"
|
||||
class="inline-flex justify-center items-center ml-4 mr-2"
|
||||
>
|
||||
<GoogleIcon
|
||||
class="text-xl"
|
||||
:name="icon"
|
||||
outline
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="name"
|
||||
v-text="$t(name)"
|
||||
class="text-sm tracking-wide truncate"
|
||||
/>
|
||||
</div>
|
||||
<GoogleIcon
|
||||
:name="isOpen ? 'expand_less' : 'expand_more'"
|
||||
class="text-lg mr-2"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Submenu -->
|
||||
<ul
|
||||
v-show="isOpen"
|
||||
class="bg-gray-50 dark:bg-gray-900/50"
|
||||
>
|
||||
<slot />
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
62
src/components/Holos/Skeleton/Sidebar/SubLink.vue
Normal file
62
src/components/Holos/Skeleton/Sidebar/SubLink.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
|
||||
import useLeftSidebar from '@Stores/LeftSidebar';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
/** Definidores */
|
||||
const leftSidebar = useLeftSidebar();
|
||||
const vroute = useRoute();
|
||||
|
||||
/** Propiedades */
|
||||
const props = defineProps({
|
||||
icon: String,
|
||||
name: String,
|
||||
to: String
|
||||
});
|
||||
|
||||
const classes = computed(() => {
|
||||
let status = props.to === vroute.name
|
||||
? 'bg-indigo-100/50 dark:bg-indigo-900/30 border-indigo-500 dark:border-indigo-400 text-indigo-700 dark:text-indigo-300'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400';
|
||||
|
||||
return `flex items-center h-10 focus:outline-hidden hover:bg-gray-100 dark:hover:bg-gray-800 border-l-4 hover:border-indigo-400 dark:hover:border-indigo-500 pr-6 ${status} transition`;
|
||||
});
|
||||
|
||||
const closeSidebar = () => {
|
||||
if(TwScreen.isDevice('phone') || TwScreen.isDevice('tablet')) {
|
||||
leftSidebar.close();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li @click="closeSidebar()">
|
||||
<RouterLink
|
||||
:class="classes"
|
||||
:to="$view({name:to})"
|
||||
>
|
||||
<span
|
||||
v-if="icon"
|
||||
class="inline-flex justify-center items-center ml-8 mr-2"
|
||||
>
|
||||
<GoogleIcon
|
||||
class="text-lg"
|
||||
:name="icon"
|
||||
outline
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="ml-8"
|
||||
/>
|
||||
<span
|
||||
v-if="name"
|
||||
v-text="$t(name)"
|
||||
class="text-sm tracking-wide truncate"
|
||||
/>
|
||||
<slot />
|
||||
</RouterLink>
|
||||
</li>
|
||||
</template>
|
||||
@ -32,7 +32,22 @@ const formattedSubtotal = computed(() => {
|
||||
const canIncrement = computed(() => {
|
||||
// Si tiene seriales, no permitir incremento directo
|
||||
if (props.item.track_serials) return false;
|
||||
return props.item.quantity < props.item.max_stock;
|
||||
// Si permite decimales, incrementar en 1
|
||||
const incrementValue = props.item.allows_decimals ? 1 : 1;
|
||||
return (props.item.quantity + incrementValue) <= props.item.max_stock;
|
||||
});
|
||||
|
||||
const canEditQuantity = computed(() => {
|
||||
// No permitir edición directa si tiene seriales
|
||||
return !props.item.track_serials;
|
||||
});
|
||||
|
||||
const quantityStep = computed(() => {
|
||||
return props.item.allows_decimals ? '0.001' : '1';
|
||||
});
|
||||
|
||||
const quantityMin = computed(() => {
|
||||
return props.item.allows_decimals ? '0.001' : '1';
|
||||
});
|
||||
|
||||
const hasSerials = computed(() => props.item.track_serials);
|
||||
@ -47,18 +62,33 @@ const needsSerialSelection = computed(() => {
|
||||
/** Métodos */
|
||||
const increment = () => {
|
||||
if (canIncrement.value) {
|
||||
emit('update-quantity', props.item.inventory_id, props.item.quantity + 1);
|
||||
const incrementValue = props.item.allows_decimals ? 1 : 1;
|
||||
const newQuantity = props.item.quantity + incrementValue;
|
||||
emit('update-quantity', props.item.inventory_id, newQuantity);
|
||||
}
|
||||
};
|
||||
|
||||
const decrement = () => {
|
||||
if (props.item.quantity > 1) {
|
||||
emit('update-quantity', props.item.inventory_id, props.item.quantity - 1);
|
||||
const decrementValue = props.item.allows_decimals ? 1 : 1;
|
||||
const minValue = props.item.allows_decimals ? 0.001 : 1;
|
||||
|
||||
if (props.item.quantity > minValue) {
|
||||
const newQuantity = Math.max(minValue, props.item.quantity - decrementValue);
|
||||
emit('update-quantity', props.item.inventory_id, newQuantity);
|
||||
} else {
|
||||
emit('remove', props.item.inventory_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuantityInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const numValue = parseFloat(value);
|
||||
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
emit('update-quantity', props.item.inventory_id, numValue);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
emit('remove', props.item.inventory_id);
|
||||
};
|
||||
@ -81,7 +111,7 @@ const remove = () => {
|
||||
</div>
|
||||
|
||||
<!-- SKU y precio unitario -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{{ item.sku }}
|
||||
</span>
|
||||
@ -91,6 +121,13 @@ const remove = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje para productos con decimales -->
|
||||
<div v-if="item.allows_decimals && item.unit_of_measure" class="mb-2">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ item.unit_of_measure.name }} ({{ item.unit_of_measure.abbreviation }}) - Permite decimales
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fila inferior: Controles de cantidad y subtotal -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Controles de cantidad -->
|
||||
@ -100,13 +137,23 @@ const remove = () => {
|
||||
class="w-7 h-7 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 transition-colors"
|
||||
@click="decrement"
|
||||
>
|
||||
<GoogleIcon
|
||||
:name="item.quantity === 1 ? 'delete' : 'remove'"
|
||||
<GoogleIcon
|
||||
:name="item.quantity <= (item.allows_decimals ? 0.001 : 1) ? 'delete' : 'remove'"
|
||||
class="text-base"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span class="w-10 text-center text-base font-bold text-gray-800 dark:text-gray-200">
|
||||
<input
|
||||
v-if="canEditQuantity"
|
||||
type="number"
|
||||
:value="item.quantity"
|
||||
:min="quantityMin"
|
||||
:step="quantityStep"
|
||||
:max="item.max_stock"
|
||||
@change="handleQuantityInput"
|
||||
class="w-16 text-center text-base font-bold text-gray-800 dark:text-gray-200 bg-transparent border border-gray-300 dark:border-gray-600 rounded px-1 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
<span v-else class="w-16 text-center text-base font-bold text-gray-800 dark:text-gray-200">
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
|
||||
|
||||
@ -16,6 +16,32 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const usoCfdiOptions = [
|
||||
{ value: 'G01', label: 'G01 - Adquisición de mercancías' },
|
||||
{ value: 'G02', label: 'G02 - Devoluciones, descuentos o bonificaciones' },
|
||||
{ value: 'G03', label: 'G03 - Gastos en general' },
|
||||
{ value: 'I01', label: 'I01 - Construcciones' },
|
||||
{ value: 'I02', label: 'I02 - Mobiliario y equipo de oficina por inversiones' },
|
||||
{ value: 'I03', label: 'I03 - Equipo de transporte' },
|
||||
{ value: 'I04', label: 'I04 - Equipo de computo y accesorios' },
|
||||
{ value: 'I05', label: 'I05 - Dados, troqueles, moldes, matrices y herramental' },
|
||||
{ value: 'I06', label: 'I06 - Comunicaciones telefónicas' },
|
||||
{ value: 'I07', label: 'I07 - Comunicaciones satelitales' },
|
||||
{ value: 'I08', label: 'I08 - Otra maquinaria y equipo' },
|
||||
{ value: 'S01', label: 'S01 - Sin efectos fiscales' }
|
||||
];
|
||||
|
||||
const regimenFiscalOptions = [
|
||||
{ value: '601', label: '601 - General de Ley Personas Morales' },
|
||||
{ value: '603', label: '603 - Personas Morales con Fines no Lucrativos' },
|
||||
{ value: '610', label: '610 - Residentes en el Extranjero sin Establecimiento Permanentes en México' },
|
||||
{ value: '620', label: '620 - Sociedades Cooperativas de Producción que optan por diferir sus ingresos' },
|
||||
{ value: '622', label: '622 - Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras' },
|
||||
{ value: '623', label: '623 - Opcional para Grupos de Sociedades' },
|
||||
{ value: '624', label: '624 - Coordinados' },
|
||||
{ value: '626', label: '626 - Régimen Simplificado de Confianza' }
|
||||
];
|
||||
|
||||
/** Emits */
|
||||
const emit = defineEmits(['close', 'save']);
|
||||
|
||||
@ -25,9 +51,12 @@ const form = ref({
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
rfc: ''
|
||||
rfc: '',
|
||||
razon_social: '',
|
||||
regimen_fiscal: '',
|
||||
cp_fiscal: '',
|
||||
uso_cfdi: ''
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
/** Watchers */
|
||||
@ -39,7 +68,11 @@ watch(() => props.show, (isShown) => {
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
rfc: ''
|
||||
rfc: '',
|
||||
razon_social: '',
|
||||
regimen_fiscal: '',
|
||||
cp_fiscal: '',
|
||||
uso_cfdi: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
@ -80,7 +113,11 @@ const handleClose = () => {
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
rfc: ''
|
||||
rfc: '',
|
||||
razon_social: '',
|
||||
regimen_fiscal: '',
|
||||
cp_fiscal: '',
|
||||
uso_cfdi: ''
|
||||
};
|
||||
emit('close');
|
||||
}
|
||||
@ -202,6 +239,68 @@ const handleClose = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Razón Social-->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Razón Social
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="form.razon_social"
|
||||
type="text"
|
||||
placeholder="Razón social"
|
||||
maxlength="13"
|
||||
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<GoogleIcon name="badge" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uso CFDI Select -->
|
||||
<div>
|
||||
<label for="uso_cfdi" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
Uso de CFDI
|
||||
</label>
|
||||
<select
|
||||
v-model="form.uso_cfdi"
|
||||
id="uso_cfdi"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="">Seleccionar uso de CFDI</option>
|
||||
<option
|
||||
v-for="option in usoCfdiOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Régimen Fiscal Select -->
|
||||
<div>
|
||||
<label for="regimen_fiscal" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
Régimen Fiscal
|
||||
</label>
|
||||
<select
|
||||
v-model="form.regimen_fiscal"
|
||||
id="regimen_fiscal"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="">Seleccionar régimen fiscal</option>
|
||||
<option
|
||||
v-for="option in regimenFiscalOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Dirección -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
|
||||
@ -453,7 +453,7 @@ export default {
|
||||
title: 'Punto de Venta',
|
||||
subtitle: 'Gestión de ventas y caja',
|
||||
category: 'Categorías',
|
||||
inventory: 'Inventario',
|
||||
inventory: 'Productos',
|
||||
prices: 'Precios',
|
||||
cashRegister: 'Caja',
|
||||
point: 'Punto de Venta',
|
||||
|
||||
@ -6,6 +6,8 @@ import { hasPermission } from '@Plugins/RolePermission';
|
||||
import Layout from '@Holos/Layout/App.vue';
|
||||
import Link from '@Holos/Skeleton/Sidebar/Link.vue';
|
||||
import Section from '@Holos/Skeleton/Sidebar/Section.vue';
|
||||
import DropdownMenu from '@Holos/Skeleton/Sidebar/DropdownMenu.vue';
|
||||
import SubLink from '@Holos/Skeleton/Sidebar/SubLink.vue';
|
||||
|
||||
/** Definidores */
|
||||
const loader = useLoader()
|
||||
@ -41,12 +43,22 @@ onMounted(() => {
|
||||
name="pos.category"
|
||||
to="pos.category.index"
|
||||
/>
|
||||
<Link
|
||||
v-if="hasPermission('inventario.index')"
|
||||
icon="inventory_2"
|
||||
name="pos.inventory"
|
||||
to="pos.inventory.index"
|
||||
/>
|
||||
<DropdownMenu
|
||||
icon="folder"
|
||||
name="Catálogos"
|
||||
>
|
||||
<SubLink
|
||||
v-if="hasPermission('inventario.index')"
|
||||
icon="inventory_2"
|
||||
name="pos.inventory"
|
||||
to="pos.inventory.index"
|
||||
/>
|
||||
<SubLink
|
||||
icon="accessibility"
|
||||
name="pos.clients"
|
||||
to="pos.clients.index"
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
v-if="hasPermission('warehouses.index')"
|
||||
icon="warehouse"
|
||||
@ -74,11 +86,6 @@ onMounted(() => {
|
||||
name="pos.returns"
|
||||
to="pos.returns.index"
|
||||
/>
|
||||
<Link
|
||||
icon="accessibility"
|
||||
name="pos.clients"
|
||||
to="pos.clients.index"
|
||||
/>
|
||||
<Link
|
||||
icon="leaderboard"
|
||||
name="pos.clientTiers"
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useForm, apiURL } from '@Services/Api';
|
||||
import { formatCurrency, formatDate } from '@/utils/formatters';
|
||||
import whatsappService from '@Services/whatsappService';
|
||||
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
@ -24,6 +25,7 @@ const processing = ref(false);
|
||||
const showProcessModal = ref(false);
|
||||
const showRejectModal = ref(false);
|
||||
const showUploadModal = ref(false);
|
||||
const sendingWhatsapp = ref(false);
|
||||
|
||||
const processForm = useForm({
|
||||
notes: ''
|
||||
@ -135,6 +137,48 @@ const submitReject = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Enviar factura por WhatsApp
|
||||
*/
|
||||
const sendInvoiceByWhatsapp = async () => {
|
||||
const request = props.request;
|
||||
|
||||
if (!request.client?.phone) {
|
||||
window.Notify.warning('El cliente no tiene número de teléfono registrado');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.invoice_pdf_url) {
|
||||
window.Notify.warning('La solicitud no tiene PDF de factura adjunto');
|
||||
return;
|
||||
}
|
||||
|
||||
sendingWhatsapp.value = true;
|
||||
|
||||
try {
|
||||
await whatsappService.sendInvoice({
|
||||
phone_number: request.client.phone,
|
||||
invoice_number: request.sale?.invoice_number || `SOL-${request.id}`,
|
||||
pdf_url: request.invoice_pdf_url,
|
||||
xml_url: request.invoice_xml_url || null,
|
||||
customer_name: request.client.name
|
||||
});
|
||||
|
||||
window.Notify.success('Factura enviada por WhatsApp correctamente');
|
||||
} catch (error) {
|
||||
console.error('WhatsApp Error:', error);
|
||||
|
||||
// Mostrar el error específico del backend
|
||||
const errorMsg = error?.error?.message
|
||||
|| error?.message
|
||||
|| 'Error desconocido al enviar por WhatsApp';
|
||||
|
||||
window.Notify.error(`Error: ${errorMsg}`);
|
||||
} finally {
|
||||
sendingWhatsapp.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -297,6 +341,30 @@ const submitReject = () => {
|
||||
<GoogleIcon name="download" class="text-xl text-red-600 dark:text-red-400" />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
v-if="request.invoice_pdf_url && request.client?.phone"
|
||||
@click="sendInvoiceByWhatsapp"
|
||||
:disabled="sendingWhatsapp"
|
||||
class="mt-3 w-full flex items-center justify-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors border border-green-300 dark:border-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-green-700 dark:text-green-300">
|
||||
{{ sendingWhatsapp ? 'Enviando...' : 'Enviar Factura por WhatsApp' }}
|
||||
</span>
|
||||
<span v-if="!sendingWhatsapp" class="text-xs text-green-600 dark:text-green-400">
|
||||
({{ request.client.phone }})
|
||||
</span>
|
||||
<GoogleIcon v-if="sendingWhatsapp" name="sync" class="animate-spin text-green-600" />
|
||||
</button>
|
||||
|
||||
<!-- Aviso si no tiene teléfono -->
|
||||
<p v-else-if="request.invoice_pdf_url && !request.client?.phone"
|
||||
class="mt-2 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<GoogleIcon name="warning" class="text-sm" />
|
||||
No se puede enviar por WhatsApp: el cliente no tiene teléfono registrado.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -21,6 +21,9 @@ const saleData = ref(null);
|
||||
const clientData = ref(null);
|
||||
const existingInvoiceRequest = ref(null);
|
||||
const formErrors = ref({});
|
||||
const searchingRfc = ref(false);
|
||||
const rfcSearch = ref('');
|
||||
const rfcSearchError = ref('');
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
@ -35,18 +38,35 @@ const form = ref({
|
||||
});
|
||||
|
||||
const paymentMethods = [
|
||||
{
|
||||
value: 'cash',
|
||||
label: 'Efectivo',
|
||||
},
|
||||
{
|
||||
value: 'credit_card',
|
||||
label: 'Tarjeta de Crédito',
|
||||
},
|
||||
{
|
||||
value: 'debit_card',
|
||||
label: 'Tarjeta de Débito',
|
||||
}
|
||||
{ value: 'cash', label: 'Efectivo' },
|
||||
{ value: 'credit_card', label: 'Tarjeta de Crédito' },
|
||||
{ value: 'debit_card', label: 'Tarjeta de Débito' }
|
||||
];
|
||||
|
||||
const usoCfdiOptions = [
|
||||
{ value: 'G01', label: 'G01 - Adquisición de mercancías' },
|
||||
{ value: 'G02', label: 'G02 - Devoluciones, descuentos o bonificaciones' },
|
||||
{ value: 'G03', label: 'G03 - Gastos en general' },
|
||||
{ value: 'I01', label: 'I01 - Construcciones' },
|
||||
{ value: 'I02', label: 'I02 - Mobiliario y equipo de oficina por inversiones' },
|
||||
{ value: 'I03', label: 'I03 - Equipo de transporte' },
|
||||
{ value: 'I04', label: 'I04 - Equipo de computo y accesorios' },
|
||||
{ value: 'I05', label: 'I05 - Dados, troqueles, moldes, matrices y herramental' },
|
||||
{ value: 'I06', label: 'I06 - Comunicaciones telefónicas' },
|
||||
{ value: 'I07', label: 'I07 - Comunicaciones satelitales' },
|
||||
{ value: 'I08', label: 'I08 - Otra maquinaria y equipo' },
|
||||
{ value: 'S01', label: 'S01 - Sin efectos fiscales' }
|
||||
];
|
||||
|
||||
const regimenFiscalOptions = [
|
||||
{ value: '601', label: '601 - General de Ley Personas Morales' },
|
||||
{ value: '603', label: '603 - Personas Morales con Fines no Lucrativos' },
|
||||
{ value: '610', label: '610 - Residentes en el Extranjero sin Establecimiento Permanente en México' },
|
||||
{ value: '620', label: '620 - Sociedades Cooperativas de Producción que optan por diferir sus ingresos' },
|
||||
{ value: '622', label: '622 - Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras' },
|
||||
{ value: '623', label: '623 - Opcional para Grupos de Sociedades' },
|
||||
{ value: '624', label: '624 - Coordinados' },
|
||||
{ value: '626', label: '626 - Régimen Simplificado de Confianza' }
|
||||
];
|
||||
|
||||
const paymentMethodLabel = computed(() => {
|
||||
@ -70,101 +90,161 @@ const latestRequest = computed(() => {
|
||||
|
||||
const canRequestInvoice = computed(() => {
|
||||
if (!latestRequest.value) return true;
|
||||
// Solo permitir nueva solicitud si la última fue rechazada
|
||||
return latestRequest.value.status === 'rejected';
|
||||
});
|
||||
|
||||
/** Helpers para mostrar labels legibles */
|
||||
const getRegimenFiscalLabel = (value) => {
|
||||
const option = regimenFiscalOptions.find(o => o.value === value);
|
||||
return option ? option.label : value || 'No registrado';
|
||||
};
|
||||
|
||||
const getUsoCfdiLabel = (value) => {
|
||||
const option = usoCfdiOptions.find(o => o.value === value);
|
||||
return option ? option.label : value || 'No registrado';
|
||||
};
|
||||
|
||||
/** Métodos */
|
||||
const fetchSaleData = async () => {
|
||||
const fetchSaleData = () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiURL(`facturacion/${invoiceNumber.value}`), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
window.axios.get(apiURL(`facturacion/${invoiceNumber.value}`))
|
||||
.then(({ data }) => {
|
||||
if (data.status === 'success') {
|
||||
saleData.value = data.data.sale;
|
||||
|
||||
// Si la venta ya tiene un cliente asociado, cargar sus datos
|
||||
if (data.data.client) {
|
||||
clientData.value = data.data.client;
|
||||
fillFormWithClient(data.data.client);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
})
|
||||
.catch(({ response }) => {
|
||||
if (response?.status === 404) {
|
||||
error.value = 'No se encontró la venta con el folio proporcionado.';
|
||||
} else if (response.status === 400) {
|
||||
// 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 if (response?.status === 400 || response?.status === 422) {
|
||||
error.value = response.data?.data?.message || response.data?.message || 'Esta venta ya tiene una solicitud de facturación.';
|
||||
existingInvoiceRequest.value = response.data?.data?.invoice_request || null;
|
||||
} else {
|
||||
error.value = result.message || 'Error al obtener los datos de la venta.';
|
||||
error.value = response?.data?.message || 'Error al obtener los datos de la venta.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
saleData.value = result.data.sale;
|
||||
clientData.value = result.data.client || null;
|
||||
/**
|
||||
* Llenar el formulario con los datos del cliente
|
||||
*/
|
||||
const fillFormWithClient = (client) => {
|
||||
form.value = {
|
||||
name: client.name || '',
|
||||
email: client.email || '',
|
||||
phone: client.phone || '',
|
||||
address: client.address || '',
|
||||
rfc: client.rfc || '',
|
||||
razon_social: client.razon_social || '',
|
||||
regimen_fiscal: client.regimen_fiscal || '',
|
||||
cp_fiscal: client.cp_fiscal || '',
|
||||
uso_cfdi: client.uso_cfdi || ''
|
||||
};
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
error.value = 'Error de conexión. Por favor intente más tarde.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
/**
|
||||
* Buscar cliente por RFC
|
||||
*/
|
||||
const searchClientByRfc = () => {
|
||||
const rfc = rfcSearch.value?.trim().toUpperCase();
|
||||
|
||||
if (!rfc) {
|
||||
rfcSearchError.value = 'Por favor ingrese un RFC';
|
||||
return;
|
||||
}
|
||||
|
||||
if (rfc.length < 12 || rfc.length > 13) {
|
||||
rfcSearchError.value = 'El RFC debe tener entre 12 y 13 caracteres';
|
||||
return;
|
||||
}
|
||||
|
||||
searchingRfc.value = true;
|
||||
rfcSearchError.value = '';
|
||||
|
||||
window.axios.get(apiURL(`facturacion/check-rfc?rfc=${rfc}`))
|
||||
.then(({ data }) => {
|
||||
// La respuesta viene: { status: 'success', data: { exists: true, client: {...} } }
|
||||
if (data.status === 'success' && data.data?.exists && data.data?.client) {
|
||||
clientData.value = data.data.client;
|
||||
fillFormWithClient(data.data.client);
|
||||
rfcSearch.value = '';
|
||||
window.Notify.success('Cliente encontrado. Verifica los datos antes de continuar.');
|
||||
} else if (data.status === 'success' && !data.data?.exists) {
|
||||
rfcSearchError.value = 'No se encontró ningún cliente con este RFC';
|
||||
window.Notify.warning('RFC no encontrado. Complete el formulario manualmente.');
|
||||
} else {
|
||||
rfcSearchError.value = 'No se encontró ningún cliente con este RFC';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
rfcSearchError.value = 'Error al buscar el RFC. Intente nuevamente.';
|
||||
})
|
||||
.finally(() => {
|
||||
searchingRfc.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Manejar Enter en el input de búsqueda
|
||||
*/
|
||||
const handleSearchKeypress = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
searchClientByRfc();
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
/**
|
||||
* Limpiar datos del cliente y volver al formulario limpio
|
||||
*/
|
||||
const clearFoundClient = () => {
|
||||
clientData.value = null;
|
||||
rfcSearchError.value = '';
|
||||
form.value = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
rfc: '',
|
||||
razon_social: '',
|
||||
regimen_fiscal: '',
|
||||
cp_fiscal: '',
|
||||
uso_cfdi: ''
|
||||
};
|
||||
window.Notify.info('Formulario limpio. Puede ingresar nuevos datos.');
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
submitting.value = true;
|
||||
formErrors.value = {};
|
||||
|
||||
const payload = hasClient.value
|
||||
? {
|
||||
name: clientData.value.name,
|
||||
email: clientData.value.email,
|
||||
phone: clientData.value.phone,
|
||||
address: clientData.value.address,
|
||||
rfc: clientData.value.rfc,
|
||||
razon_social: clientData.value.razon_social,
|
||||
regimen_fiscal: clientData.value.regimen_fiscal,
|
||||
cp_fiscal: clientData.value.cp_fiscal,
|
||||
uso_cfdi: clientData.value.uso_cfdi
|
||||
}
|
||||
: form.value;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiURL(`facturacion/${invoiceNumber.value}`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||
window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value)
|
||||
.then(({ data }) => {
|
||||
submitted.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (data.errors) {
|
||||
formErrors.value = data.errors;
|
||||
})
|
||||
.catch(({ response }) => {
|
||||
if (response?.status === 422 && response.data?.errors) {
|
||||
formErrors.value = response.data.errors;
|
||||
} else if (response?.data?.data?.errors) {
|
||||
formErrors.value = response.data.data.errors;
|
||||
} else {
|
||||
error.value = data.message || 'Error al enviar los datos.';
|
||||
error.value = response?.data?.message || response?.data?.data?.message || 'Error al enviar los datos.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
submitted.value = true;
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
error.value = 'Error de conexión. Por favor intente más tarde.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
/** Ciclos */
|
||||
@ -185,7 +265,7 @@ onMounted(() => {
|
||||
Solicitud de Factura
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
{{ hasClient ? 'Confirme los datos para generar su factura' : 'Complete sus datos fiscales para generar su factura' }}
|
||||
Complete sus datos fiscales para generar su factura
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -206,7 +286,6 @@ onMounted(() => {
|
||||
{{ 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:
|
||||
@ -233,7 +312,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submitted Success -->
|
||||
<!-- Success State -->
|
||||
<div v-else-if="submitted" 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-green-100 dark:bg-green-900/30 mb-4">
|
||||
<GoogleIcon name="check_circle" class="text-3xl text-green-500" />
|
||||
@ -325,13 +404,12 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historial de solicitudes (si existen) -->
|
||||
<!-- Historial de solicitudes -->
|
||||
<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">
|
||||
@ -361,7 +439,6 @@ onMounted(() => {
|
||||
</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'">
|
||||
@ -376,7 +453,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje si no puede solicitar factura -->
|
||||
<!-- 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" />
|
||||
@ -388,65 +465,130 @@ onMounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cliente asociado - Solo confirmación -->
|
||||
<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
|
||||
</h2>
|
||||
<!-- Buscador de cliente por RFC -->
|
||||
<div v-if="canRequestInvoice && !hasClient" class="bg-gradient-to-br from-gray-50 to-indigo-50 dark:from-gray-900/20 dark:to-indigo-900/20 rounded-lg shadow-lg p-6 border border-gray-200 dark:border-gray-800">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<GoogleIcon name="person_search" class="text-2xl text-gray-600 dark:text-gray-400" />
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
¿Ya eres cliente?
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Busca tu RFC para autocompletar tus datos fiscales
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="rfcSearch"
|
||||
type="text"
|
||||
placeholder="Ingresa tu RFC (ej: XAXX010101000)"
|
||||
maxlength="13"
|
||||
@keypress="handleSearchKeypress"
|
||||
:disabled="searchingRfc"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 uppercase"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="searchClientByRfc"
|
||||
type="button"
|
||||
:disabled="searchingRfc || !rfcSearch"
|
||||
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors font-medium flex items-center gap-2 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<GoogleIcon
|
||||
:name="searchingRfc ? 'sync' : 'search'"
|
||||
:class="{ 'animate-spin': searchingRfc }"
|
||||
class="text-xl"
|
||||
/>
|
||||
<span class="hidden sm:inline">
|
||||
{{ searchingRfc ? 'Buscando...' : 'Buscar' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="rfcSearchError" class="mt-3 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
<GoogleIcon name="error" class="text-lg" />
|
||||
{{ rfcSearchError }}
|
||||
</p>
|
||||
<div class="mt-4 p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<p class="text-xs text-blue-800 dark:text-blue-300 flex items-start gap-2">
|
||||
<GoogleIcon name="info" class="text-sm mt-0.5" />
|
||||
<span>Si no encuentras tu RFC, no te preocupes. Podrás llenar el formulario manualmente más abajo.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Nombre:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.name }}</span>
|
||||
<!-- Cliente encontrado -->
|
||||
<div v-if="hasClient && canRequestInvoice" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<GoogleIcon name="check_circle" class="text-xl text-green-600 dark:text-green-400" />
|
||||
Cliente Identificado
|
||||
</h2>
|
||||
<button
|
||||
@click="clearFoundClient"
|
||||
type="button"
|
||||
class="text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 flex items-center gap-1 font-medium"
|
||||
>
|
||||
<GoogleIcon name="close" class="text-lg" />
|
||||
Usar otros datos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
|
||||
<p class="text-sm text-green-800 dark:text-green-300 flex items-center gap-2">
|
||||
<GoogleIcon name="verified" class="text-lg" />
|
||||
<span class="font-medium">Datos fiscales encontrados. Verifica que sean correctos antes de continuar.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm mb-6">
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span class="text-gray-500 dark:text-gray-400 block mb-1">Nombre:</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ form.name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">RFC:</span>
|
||||
<span class="ml-2 font-semibold font-mono text-gray-900 dark:text-white">{{ clientData.rfc || 'No registrado' }}</span>
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span class="text-gray-500 dark:text-gray-400 block mb-1">RFC:</span>
|
||||
<span class="font-semibold font-mono text-gray-900 dark:text-white">{{ form.rfc || 'No registrado' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Email:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.email || 'No registrado' }}</span>
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span class="text-gray-500 dark:text-gray-400 block mb-1">Email:</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ form.email || 'No registrado' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Teléfono:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.phone || 'No registrado' }}</span>
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span class="text-gray-500 dark:text-gray-400 block mb-1">Teléfono:</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ form.phone || 'No registrado' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Razón Social:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.razon_social || 'No registrado' }}</span>
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span class="text-gray-500 dark:text-gray-400 block mb-1">Razón Social:</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ form.razon_social || 'No registrado' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Régimen Fiscal:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.regimen_fiscal || 'No registrado' }}</span>
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span class="text-gray-500 dark:text-gray-400 block mb-1">Régimen Fiscal:</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ getRegimenFiscalLabel(form.regimen_fiscal) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">C.P. Fiscal:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.cp_fiscal || 'No registrado' }}</span>
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span class="text-gray-500 dark:text-gray-400 block mb-1">C.P. Fiscal:</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ form.cp_fiscal || 'No registrado' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Uso CFDI:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.uso_cfdi || 'No registrado' }}</span>
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span class="text-gray-500 dark:text-gray-400 block mb-1">Uso CFDI:</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ getUsoCfdiLabel(form.uso_cfdi) }}</span>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">Dirección:</span>
|
||||
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.address || 'No registrado' }}</span>
|
||||
<div class="sm:col-span-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span class="text-gray-500 dark:text-gray-400 block mb-1">Dirección:</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ form.address || 'No registrado' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botón de confirmar -->
|
||||
<div class="mt-6 flex justify-center">
|
||||
<div class="flex justify-center">
|
||||
<PrimaryButton
|
||||
@click="submitForm"
|
||||
:class="{ 'opacity-25': submitting }"
|
||||
:disabled="submitting"
|
||||
class="px-8 py-3"
|
||||
>
|
||||
<template v-if="submitting">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Procesando...
|
||||
<GoogleIcon name="sync" class="animate-spin mr-2" />
|
||||
Enviando solicitud...
|
||||
</template>
|
||||
<template v-else>
|
||||
<GoogleIcon name="send" class="mr-2" />
|
||||
@ -456,29 +598,37 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
La factura se generará con los datos mostrados y se enviará al correo del cliente.
|
||||
La factura se generará con los datos mostrados y se enviará a tu correo electrónico.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sin cliente - Formulario manual -->
|
||||
<form v-else-if="canRequestInvoice" @submit.prevent="submitForm" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<!-- Formulario manual -->
|
||||
<form v-else-if="canRequestInvoice && !hasClient" @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
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-4 grid-cols-1 md:grid-cols-2">
|
||||
<Input
|
||||
v-model="form.rfc"
|
||||
id="rfc"
|
||||
title="RFC *"
|
||||
placeholder="XAXX010101000"
|
||||
maxlength="13"
|
||||
:onError="formErrors.rfc"
|
||||
/>
|
||||
<Input
|
||||
v-model="form.name"
|
||||
id="name"
|
||||
title="Nombre Completo"
|
||||
title="Nombre Completo *"
|
||||
placeholder="Juan Pérez García"
|
||||
:onError="formErrors.name"
|
||||
/>
|
||||
<Input
|
||||
v-model="form.email"
|
||||
id="email"
|
||||
title="Correo Electrónico"
|
||||
title="Correo Electrónico *"
|
||||
type="email"
|
||||
placeholder="correo@ejemplo.com"
|
||||
:onError="formErrors.email"
|
||||
@ -486,53 +636,83 @@ onMounted(() => {
|
||||
<Input
|
||||
v-model="form.phone"
|
||||
id="phone"
|
||||
title="Teléfono"
|
||||
title="Teléfono *"
|
||||
type="tel"
|
||||
placeholder="55 1234 5678"
|
||||
:onError="formErrors.phone"
|
||||
/>
|
||||
<Input
|
||||
v-model="form.rfc"
|
||||
id="rfc"
|
||||
title="RFC"
|
||||
placeholder="XAXX010101000"
|
||||
maxlength="13"
|
||||
:onError="formErrors.rfc"
|
||||
/>
|
||||
<Input
|
||||
v-model="form.razon_social"
|
||||
id="razon_social"
|
||||
title="Razón Social"
|
||||
title="Razón Social *"
|
||||
placeholder="Como aparece en la Constancia Fiscal"
|
||||
:onError="formErrors.razon_social"
|
||||
/>
|
||||
<Input
|
||||
v-model="form.regimen_fiscal"
|
||||
id="regimen_fiscal"
|
||||
title="Régimen Fiscal"
|
||||
placeholder="Ej: 601 - General de Ley"
|
||||
:onError="formErrors.regimen_fiscal"
|
||||
/>
|
||||
|
||||
<!-- Régimen Fiscal Select -->
|
||||
<div>
|
||||
<label for="regimen_fiscal" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
Régimen Fiscal *
|
||||
</label>
|
||||
<select
|
||||
v-model="form.regimen_fiscal"
|
||||
id="regimen_fiscal"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
:class="{ 'border-red-500': formErrors.regimen_fiscal }"
|
||||
>
|
||||
<option value="" disabled>Seleccionar régimen fiscal</option>
|
||||
<option
|
||||
v-for="option in regimenFiscalOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="formErrors.regimen_fiscal" class="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{{ formErrors.regimen_fiscal[0] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="form.cp_fiscal"
|
||||
id="cp_fiscal"
|
||||
title="Código Postal Fiscal"
|
||||
title="Código Postal Fiscal *"
|
||||
placeholder="06600"
|
||||
maxlength="5"
|
||||
:onError="formErrors.cp_fiscal"
|
||||
/>
|
||||
<Input
|
||||
v-model="form.uso_cfdi"
|
||||
id="uso_cfdi"
|
||||
title="Uso de CFDI"
|
||||
placeholder="Ej: G03 - Gastos en general"
|
||||
:onError="formErrors.uso_cfdi"
|
||||
/>
|
||||
|
||||
<!-- Uso CFDI Select -->
|
||||
<div>
|
||||
<label for="uso_cfdi" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
Uso de CFDI *
|
||||
</label>
|
||||
<select
|
||||
v-model="form.uso_cfdi"
|
||||
id="uso_cfdi"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
:class="{ 'border-red-500': formErrors.uso_cfdi }"
|
||||
>
|
||||
<option value="" disabled>Seleccionar uso de CFDI</option>
|
||||
<option
|
||||
v-for="option in usoCfdiOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="formErrors.uso_cfdi" class="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{{ formErrors.uso_cfdi[0] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<Input
|
||||
v-model="form.address"
|
||||
id="address"
|
||||
title="Dirección"
|
||||
title="Dirección *"
|
||||
placeholder="Calle, Número, Colonia, Ciudad, Estado"
|
||||
:onError="formErrors.address"
|
||||
/>
|
||||
@ -545,21 +725,18 @@ onMounted(() => {
|
||||
:disabled="submitting"
|
||||
>
|
||||
<template v-if="submitting">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<GoogleIcon name="sync" class="animate-spin mr-2" />
|
||||
Enviando...
|
||||
</template>
|
||||
<template v-else>
|
||||
<GoogleIcon name="send" class="mr-2" />
|
||||
Solicitar Factura
|
||||
Enviar Solicitud de Factura
|
||||
</template>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Al enviar este formulario, acepta que sus datos serán utilizados únicamente para la emisión de su factura fiscal.
|
||||
* Campos obligatorios. Al enviar este formulario, acepta que sus datos serán utilizados únicamente para la emisión de su factura fiscal.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useForm, apiURL } from '@Services/Api';
|
||||
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
@ -16,6 +16,7 @@ const props = defineProps({
|
||||
|
||||
/** Estado */
|
||||
const categories = ref([]);
|
||||
const units = ref([]);
|
||||
|
||||
/** Formulario */
|
||||
const form = useForm({
|
||||
@ -23,8 +24,21 @@ const form = useForm({
|
||||
sku: '',
|
||||
barcode: '',
|
||||
category_id: '',
|
||||
unit_of_measure_id: null,
|
||||
retail_price: 0,
|
||||
tax: 16
|
||||
tax: 16,
|
||||
track_serials: false
|
||||
});
|
||||
|
||||
/** Computed */
|
||||
const selectedUnit = computed(() => {
|
||||
if (!form.unit_of_measure_id) return null;
|
||||
return units.value.find(u => u.id === form.unit_of_measure_id);
|
||||
});
|
||||
|
||||
const canUseSerials = computed(() => {
|
||||
if (!selectedUnit.value) return true;
|
||||
return !selectedUnit.value.allows_decimals;
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
@ -45,6 +59,36 @@ const loadCategories = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadUnits = async () => {
|
||||
try {
|
||||
const response = await fetch(apiURL('unidades-medida/active'), {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.data && result.data.units) {
|
||||
units.value = result.data.units;
|
||||
|
||||
// Pre-seleccionar "Pieza" si existe
|
||||
const defaultUnit = units.value.find(u => u.abbreviation === 'u');
|
||||
if (defaultUnit && !form.unit_of_measure_id) {
|
||||
form.unit_of_measure_id = defaultUnit.id;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading units:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const validateSerialsAndUnit = () => {
|
||||
if (form.track_serials && selectedUnit.value && selectedUnit.value.allows_decimals) {
|
||||
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
|
||||
form.track_serials = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createProduct = () => {
|
||||
form.post(apiURL('inventario'), {
|
||||
onSuccess: () => {
|
||||
@ -67,8 +111,17 @@ const closeModal = () => {
|
||||
watch(() => props.show, (newValue) => {
|
||||
if (newValue) {
|
||||
loadCategories();
|
||||
loadUnits();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => form.unit_of_measure_id, () => {
|
||||
validateSerialsAndUnit();
|
||||
});
|
||||
|
||||
watch(() => form.track_serials, () => {
|
||||
validateSerialsAndUnit();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -156,6 +209,51 @@ watch(() => props.show, (newValue) => {
|
||||
<FormError :message="form.errors?.category_id" />
|
||||
</div>
|
||||
|
||||
<!-- Unidad de Medida -->
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
UNIDAD DE MEDIDA *
|
||||
</label>
|
||||
<select
|
||||
v-model="form.unit_of_measure_id"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option :value="null">Seleccionar unidad</option>
|
||||
<option
|
||||
v-for="unit in units"
|
||||
:key="unit.id"
|
||||
:value="unit.id"
|
||||
>
|
||||
{{ unit.name }} ({{ unit.abbreviation }})
|
||||
</option>
|
||||
</select>
|
||||
<FormError :message="form.errors?.unit_of_measure_id" />
|
||||
<p v-if="selectedUnit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-if="selectedUnit.allows_decimals">Esta unidad permite cantidades decimales (ej: 25.750)</span>
|
||||
<span v-else>Esta unidad solo permite cantidades enteras</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Track Serials -->
|
||||
<div class="col-span-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.track_serials"
|
||||
type="checkbox"
|
||||
:disabled="!canUseSerials"
|
||||
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Rastrear números de serie
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="selectedUnit && selectedUnit.allows_decimals" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
No se pueden usar números de serie con esta unidad de medida.
|
||||
</p>
|
||||
<FormError :message="form.errors?.track_serials" />
|
||||
</div>
|
||||
|
||||
<!-- Precio de Venta -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useForm, apiURL } from '@Services/Api';
|
||||
|
||||
@ -21,6 +21,7 @@ const props = defineProps({
|
||||
|
||||
/** Estado */
|
||||
const categories = ref([]);
|
||||
const units = ref([]);
|
||||
|
||||
/** Formulario */
|
||||
const form = useForm({
|
||||
@ -28,8 +29,21 @@ const form = useForm({
|
||||
sku: '',
|
||||
barcode: '',
|
||||
category_id: '',
|
||||
unit_of_measure_id: null,
|
||||
retail_price: 0,
|
||||
tax: 16
|
||||
tax: 16,
|
||||
track_serials: false
|
||||
});
|
||||
|
||||
/** Computed */
|
||||
const selectedUnit = computed(() => {
|
||||
if (!form.unit_of_measure_id) return null;
|
||||
return units.value.find(u => u.id === form.unit_of_measure_id);
|
||||
});
|
||||
|
||||
const canUseSerials = computed(() => {
|
||||
if (!selectedUnit.value) return true;
|
||||
return !selectedUnit.value.allows_decimals;
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
@ -50,6 +64,30 @@ const loadCategories = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadUnits = async () => {
|
||||
try {
|
||||
const response = await fetch(apiURL('unidades-medida/active'), {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.data && result.data.units) {
|
||||
units.value = result.data.units;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading units:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const validateSerialsAndUnit = () => {
|
||||
if (form.track_serials && selectedUnit.value && selectedUnit.value.allows_decimals) {
|
||||
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
|
||||
form.track_serials = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateProduct = () => {
|
||||
form.put(apiURL(`inventario/${props.product.id}`), {
|
||||
onSuccess: () => {
|
||||
@ -80,17 +118,28 @@ watch(() => props.product, (newProduct) => {
|
||||
form.sku = newProduct.sku || '';
|
||||
form.barcode = newProduct.barcode || '';
|
||||
form.category_id = newProduct.category_id || '';
|
||||
form.unit_of_measure_id = newProduct.unit_of_measure_id || null;
|
||||
form.cost = parseFloat(newProduct.price?.cost || 0);
|
||||
form.retail_price = parseFloat(newProduct.price?.retail_price || 0);
|
||||
form.tax = parseFloat(newProduct.price?.tax || 16);
|
||||
form.track_serials = newProduct.track_serials || false;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.show, (newValue) => {
|
||||
if (newValue) {
|
||||
loadCategories();
|
||||
loadUnits();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => form.unit_of_measure_id, () => {
|
||||
validateSerialsAndUnit();
|
||||
});
|
||||
|
||||
watch(() => form.track_serials, () => {
|
||||
validateSerialsAndUnit();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -178,6 +227,51 @@ watch(() => props.show, (newValue) => {
|
||||
<FormError :message="form.errors?.category_id" />
|
||||
</div>
|
||||
|
||||
<!-- Unidad de Medida -->
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
UNIDAD DE MEDIDA *
|
||||
</label>
|
||||
<select
|
||||
v-model="form.unit_of_measure_id"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option :value="null">Seleccionar unidad</option>
|
||||
<option
|
||||
v-for="unit in units"
|
||||
:key="unit.id"
|
||||
:value="unit.id"
|
||||
>
|
||||
{{ unit.name }} ({{ unit.abbreviation }})
|
||||
</option>
|
||||
</select>
|
||||
<FormError :message="form.errors?.unit_of_measure_id" />
|
||||
<p v-if="selectedUnit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-if="selectedUnit.allows_decimals">Esta unidad permite cantidades decimales (ej: 25.750)</span>
|
||||
<span v-else>Esta unidad solo permite cantidades enteras</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Track Serials -->
|
||||
<div class="col-span-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.track_serials"
|
||||
type="checkbox"
|
||||
:disabled="!canUseSerials"
|
||||
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Rastrear números de serie
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="selectedUnit && selectedUnit.allows_decimals" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
No se pueden usar números de serie con esta unidad de medida.
|
||||
</p>
|
||||
<FormError :message="form.errors?.track_serials" />
|
||||
</div>
|
||||
|
||||
<!-- Precio de Venta -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
@ -215,6 +309,7 @@ watch(() => props.show, (newValue) => {
|
||||
<!-- Botones -->
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<button
|
||||
v-if="canUseSerials"
|
||||
type="button"
|
||||
@click="openSerials"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-lg hover:bg-emerald-100 transition-colors"
|
||||
@ -222,6 +317,10 @@ watch(() => props.show, (newValue) => {
|
||||
<GoogleIcon name="qr_code_2" class="text-lg" />
|
||||
Gestionar Seriales
|
||||
</button>
|
||||
<div v-else class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-400 bg-gray-50 border border-gray-200 rounded-lg cursor-not-allowed" :title="selectedUnit?.allows_decimals ? `No disponible: ${selectedUnit.name} permite decimales` : 'Selecciona una unidad de medida'">
|
||||
<GoogleIcon name="qr_code_2" class="text-lg opacity-50" />
|
||||
Gestionar Seriales
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -377,12 +377,20 @@ onMounted(() => {
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
v-if="!model.unit_of_measure?.allows_decimals"
|
||||
@click="openSerials(model)"
|
||||
class="text-emerald-600 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-300 transition-colors"
|
||||
title="Gestionar números de serie"
|
||||
>
|
||||
<GoogleIcon name="qr_code_2" class="text-xl" />
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-gray-400 dark:text-gray-600 cursor-not-allowed"
|
||||
:title="`No disponible: ${model.unit_of_measure.name} (${model.unit_of_measure.abbreviation}) permite decimales`"
|
||||
>
|
||||
<GoogleIcon name="qr_code_2" class="text-xl opacity-30" />
|
||||
</span>
|
||||
<button
|
||||
v-if="can('edit')"
|
||||
@click="openEditModal(model)"
|
||||
|
||||
@ -65,6 +65,10 @@ const totalCost = computed(() => {
|
||||
return form.quantity * form.unit_cost;
|
||||
});
|
||||
|
||||
const allowsDecimals = computed(() => {
|
||||
return props.movement?.inventory?.unit_of_measure?.allows_decimals || false;
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const loadWarehouses = () => {
|
||||
loading.value = true;
|
||||
@ -205,10 +209,10 @@ watch(() => props.show, (isShown) => {
|
||||
<FormInput
|
||||
v-model="form.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
required
|
||||
:min="allowsDecimals ? '0.001' : '1'"
|
||||
:step="allowsDecimals ? '0.001' : '1'"
|
||||
:placeholder="allowsDecimals ? '0.000' : '0'"
|
||||
required
|
||||
/>
|
||||
<FormError :message="form.errors?.quantity" />
|
||||
</div>
|
||||
|
||||
@ -52,6 +52,12 @@ const totalQuantity = computed(() => {
|
||||
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||
});
|
||||
|
||||
// Determina si un producto puede usar seriales (no puede si la unidad permite decimales)
|
||||
const canUseSerials = (item) => {
|
||||
if (!item.unit_of_measure) return true;
|
||||
return !item.allows_decimals;
|
||||
};
|
||||
|
||||
/** Métodos */
|
||||
const loadData = () => {
|
||||
loading.value = true;
|
||||
@ -86,6 +92,8 @@ const addProduct = () => {
|
||||
quantity: 1,
|
||||
unit_cost: 0,
|
||||
track_serials: false,
|
||||
unit_of_measure: null,
|
||||
allows_decimals: false,
|
||||
serial_numbers_text: '', // Texto con seriales separados por líneas
|
||||
serial_validation_error: ''
|
||||
});
|
||||
@ -160,6 +168,13 @@ const selectProduct = (product) => {
|
||||
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
||||
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
||||
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
|
||||
selectedProducts.value[currentSearchIndex.value].unit_of_measure = product.unit_of_measure || null;
|
||||
selectedProducts.value[currentSearchIndex.value].allows_decimals = product.unit_of_measure?.allows_decimals || false;
|
||||
|
||||
// Limpiar seriales si la unidad permite decimales
|
||||
if (product.unit_of_measure?.allows_decimals) {
|
||||
selectedProducts.value[currentSearchIndex.value].serial_numbers_text = '';
|
||||
}
|
||||
}
|
||||
|
||||
productSearch.value = '';
|
||||
@ -184,6 +199,11 @@ const countSerials = (item) => {
|
||||
|
||||
// Actualizar cantidad según seriales ingresados
|
||||
const updateQuantityFromSerials = (item) => {
|
||||
// Solo actualizar cantidad automáticamente si el producto requiere seriales y puede usarlos
|
||||
if (!item.track_serials || !canUseSerials(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Contar seriales válidos (sin líneas vacías)
|
||||
const serialCount = countSerials(item);
|
||||
|
||||
@ -194,7 +214,6 @@ const updateQuantityFromSerials = (item) => {
|
||||
// Si no hay seriales, resetear a 1
|
||||
item.quantity = 1;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const createEntry = () => {
|
||||
@ -203,11 +222,12 @@ const createEntry = () => {
|
||||
const productData = {
|
||||
inventory_id: item.inventory_id,
|
||||
quantity: Number(item.quantity),
|
||||
unit_cost: Number(item.unit_cost)
|
||||
unit_cost: Number(item.unit_cost),
|
||||
serial_numbers: [] // Inicializar siempre como array vacío
|
||||
};
|
||||
|
||||
// Agregar seriales si hay texto ingresado
|
||||
if (item.serial_numbers_text && item.serial_numbers_text.trim()) {
|
||||
// Agregar seriales solo si la unidad lo permite y hay texto ingresado
|
||||
if (canUseSerials(item) && item.serial_numbers_text && item.serial_numbers_text.trim()) {
|
||||
// Limpiar y filtrar seriales - eliminar líneas vacías, espacios, y duplicados
|
||||
const serials = item.serial_numbers_text
|
||||
.split('\n')
|
||||
@ -420,8 +440,12 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
:disabled="item.track_serials && canUseSerials(item)"
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-gray-900"
|
||||
/>
|
||||
<p v-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Controlado por seriales
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Costo unitario -->
|
||||
@ -458,24 +482,33 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
<div v-if="item.inventory_id" class="mt-3">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Números de Serie
|
||||
<span v-if="item.track_serials" class="text-red-500">*</span>
|
||||
<span v-else class="text-gray-500 font-normal">(opcional)</span>
|
||||
<span class="text-gray-500 font-normal">- uno por línea, debe coincidir con la cantidad</span>
|
||||
<span v-if="item.track_serials && canUseSerials(item)" class="text-red-500">*</span>
|
||||
<span v-else-if="canUseSerials(item)" class="text-gray-500 font-normal">(opcional)</span>
|
||||
<span v-if="canUseSerials(item)" class="text-gray-500 font-normal">- uno por línea, debe coincidir con la cantidad</span>
|
||||
</label>
|
||||
|
||||
<!-- Advertencia si la unidad permite decimales -->
|
||||
<div v-if="!canUseSerials(item)" class="p-2 mb-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded">
|
||||
<div class="flex items-start gap-2">
|
||||
<GoogleIcon name="warning" class="text-amber-600 dark:text-amber-400 text-sm shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-amber-800 dark:text-amber-200">
|
||||
Este producto usa la unidad <strong>{{ item.unit_of_measure?.name }} ({{ item.unit_of_measure?.abbreviation }})</strong> que permite cantidades decimales. No se pueden agregar números de serie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="item.serial_numbers_text"
|
||||
@input="updateQuantityFromSerials(item)"
|
||||
rows="3"
|
||||
:disabled="!canUseSerials(item)"
|
||||
placeholder="Ingresa los números de serie, uno por línea Ejemplo: IMEI-123456 IMEI-789012"
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 font-mono"
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-gray-900"
|
||||
></textarea>
|
||||
<div v-if="countSerials(item) > 0" class="mt-1 flex items-start gap-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div v-if="countSerials(item) > 0 && canUseSerials(item)" class="mt-1 flex items-start gap-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<GoogleIcon name="qr_code_2" class="text-sm shrink-0" />
|
||||
<span>{{ countSerials(item) }} serial(es) ingresado(s)</span>
|
||||
</div>
|
||||
<p v-else class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Si no ingresas seriales, será una entrada sin tracking de números de serie
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Subtotal del producto -->
|
||||
|
||||
@ -59,6 +59,12 @@ const totalCost = computed(() => {
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Determina si un producto puede usar seriales (no puede si la unidad permite decimales)
|
||||
const canUseSerials = (item) => {
|
||||
if (!item.unit_of_measure) return true;
|
||||
return !item.allows_decimals;
|
||||
};
|
||||
|
||||
/** Métodos */
|
||||
const loadData = () => {
|
||||
loading.value = true;
|
||||
@ -102,6 +108,8 @@ const addProduct = () => {
|
||||
quantity: 1,
|
||||
unit_cost: 0,
|
||||
track_serials: false,
|
||||
unit_of_measure: null,
|
||||
allows_decimals: false,
|
||||
selected_serials: [], // Array de números de serie seleccionados
|
||||
available_serials_count: 0
|
||||
});
|
||||
@ -186,6 +194,13 @@ const selectProduct = (product) => {
|
||||
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
||||
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
|
||||
selectedProducts.value[currentSearchIndex.value].available_serials_count = product.warehouse_stock || 0;
|
||||
selectedProducts.value[currentSearchIndex.value].unit_of_measure = product.unit_of_measure || null;
|
||||
selectedProducts.value[currentSearchIndex.value].allows_decimals = product.unit_of_measure?.allows_decimals || false;
|
||||
|
||||
// Limpiar seriales si la unidad permite decimales
|
||||
if (product.unit_of_measure?.allows_decimals) {
|
||||
selectedProducts.value[currentSearchIndex.value].selected_serials = [];
|
||||
}
|
||||
}
|
||||
|
||||
productSearch.value = '';
|
||||
@ -225,9 +240,9 @@ const handleSerialsConfirmed = ({ serialNumbers, quantity }) => {
|
||||
};
|
||||
|
||||
const createExit = () => {
|
||||
// Validar que productos con seriales tengan seriales seleccionados
|
||||
// Validar que productos con seriales (y que pueden usarlos) tengan seriales seleccionados
|
||||
const invalidProducts = selectedProducts.value.filter(
|
||||
item => item.track_serials && (!item.selected_serials || item.selected_serials.length === 0)
|
||||
item => item.track_serials && canUseSerials(item) && (!item.selected_serials || item.selected_serials.length === 0)
|
||||
);
|
||||
|
||||
if (invalidProducts.length > 0) {
|
||||
@ -239,11 +254,12 @@ const createExit = () => {
|
||||
form.products = selectedProducts.value.map(item => {
|
||||
const productData = {
|
||||
inventory_id: item.inventory_id,
|
||||
quantity: Number(item.quantity)
|
||||
quantity: Number(item.quantity),
|
||||
serial_numbers: [] // Inicializar siempre como array vacío
|
||||
};
|
||||
|
||||
// Agregar seriales si el producto los requiere
|
||||
if (item.track_serials && item.selected_serials && item.selected_serials.length > 0) {
|
||||
// Agregar seriales solo si la unidad lo permite y hay seriales seleccionados
|
||||
if (item.track_serials && canUseSerials(item) && item.selected_serials && item.selected_serials.length > 0) {
|
||||
productData.serial_numbers = item.selected_serials;
|
||||
}
|
||||
|
||||
@ -463,15 +479,18 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
<input
|
||||
v-model="item.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
:disabled="item.track_serials"
|
||||
:min="item.allows_decimals ? '0.001' : '1'"
|
||||
:step="item.allows_decimals ? '0.001' : '1'"
|
||||
:placeholder="item.allows_decimals ? '0.000' : '0'"
|
||||
:disabled="item.track_serials && canUseSerials(item)"
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<p v-if="item.track_serials" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p v-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Controlado por seriales
|
||||
</p>
|
||||
<p v-else-if="item.allows_decimals" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Permite hasta 3 decimales (ej: 25.750 {{ item.unit_of_measure?.abbreviation }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Botón eliminar -->
|
||||
@ -490,7 +509,22 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
|
||||
<!-- Selección de seriales (solo si el producto requiere seriales) -->
|
||||
<div v-if="item.inventory_id && item.track_serials" class="col-span-12">
|
||||
<div class="mt-2 p-2 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<!-- Advertencia si la unidad permite decimales -->
|
||||
<div v-if="!canUseSerials(item)" class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<GoogleIcon name="warning" class="text-red-600 dark:text-red-400 text-lg shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-red-900 dark:text-red-100">
|
||||
No se pueden usar números de serie con esta unidad de medida
|
||||
</p>
|
||||
<p class="text-xs text-red-700 dark:text-red-300 mt-1">
|
||||
Este producto usa la unidad <strong>{{ item.unit_of_measure?.name }} ({{ item.unit_of_measure?.abbreviation }})</strong> que permite cantidades decimales.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-2 p-2 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<GoogleIcon name="qr_code_2" class="text-amber-600 dark:text-amber-400 text-lg" />
|
||||
|
||||
@ -59,6 +59,12 @@ const totalQuantity = computed(() => {
|
||||
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||
});
|
||||
|
||||
// Determina si un producto puede usar seriales (no puede si la unidad permite decimales)
|
||||
const canUseSerials = (item) => {
|
||||
if (!item.unit_of_measure) return true;
|
||||
return !item.allows_decimals;
|
||||
};
|
||||
|
||||
/** Métodos */
|
||||
const loadData = () => {
|
||||
loading.value = true;
|
||||
@ -109,6 +115,8 @@ const addProduct = () => {
|
||||
product_sku: '',
|
||||
quantity: 1,
|
||||
track_serials: false,
|
||||
unit_of_measure: null,
|
||||
allows_decimals: false,
|
||||
selected_serials: [], // Array de números de serie seleccionados
|
||||
available_serials_count: 0
|
||||
});
|
||||
@ -193,6 +201,13 @@ const selectProduct = (product) => {
|
||||
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
||||
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
|
||||
selectedProducts.value[currentSearchIndex.value].available_serials_count = product.warehouse_stock || 0;
|
||||
selectedProducts.value[currentSearchIndex.value].unit_of_measure = product.unit_of_measure || null;
|
||||
selectedProducts.value[currentSearchIndex.value].allows_decimals = product.unit_of_measure?.allows_decimals || false;
|
||||
|
||||
// Limpiar seriales si la unidad permite decimales
|
||||
if (product.unit_of_measure?.allows_decimals) {
|
||||
selectedProducts.value[currentSearchIndex.value].selected_serials = [];
|
||||
}
|
||||
}
|
||||
|
||||
productSearch.value = '';
|
||||
@ -232,9 +247,9 @@ const handleSerialsConfirmed = ({ serialNumbers, quantity }) => {
|
||||
};
|
||||
|
||||
const createTransfer = () => {
|
||||
// Validar que productos con seriales tengan seriales seleccionados
|
||||
// Validar que productos con seriales (y que pueden usarlos) tengan seriales seleccionados
|
||||
const invalidProducts = selectedProducts.value.filter(
|
||||
item => item.track_serials && (!item.selected_serials || item.selected_serials.length === 0)
|
||||
item => item.track_serials && canUseSerials(item) && (!item.selected_serials || item.selected_serials.length === 0)
|
||||
);
|
||||
|
||||
if (invalidProducts.length > 0) {
|
||||
@ -246,19 +261,20 @@ const createTransfer = () => {
|
||||
form.products = selectedProducts.value.map(item => {
|
||||
const productData = {
|
||||
inventory_id: item.inventory_id,
|
||||
quantity: Number(item.quantity)
|
||||
quantity: Number(item.quantity),
|
||||
serial_numbers: [] // Inicializar siempre como array vacío
|
||||
};
|
||||
|
||||
// Agregar seriales si el producto los requiere
|
||||
if (item.track_serials && item.selected_serials && item.selected_serials.length > 0) {
|
||||
// Agregar seriales solo si la unidad lo permite y hay seriales seleccionados
|
||||
if (item.track_serials && canUseSerials(item) && item.selected_serials && item.selected_serials.length > 0) {
|
||||
// Filtrar seriales válidos (no null, no undefined, no vacíos)
|
||||
const validSerials = item.selected_serials.filter(s => s && s.trim());
|
||||
|
||||
|
||||
if (validSerials.length !== item.quantity) {
|
||||
window.Notify.error(`El producto "${item.product_name}" tiene ${validSerials.length} seriales pero cantidad ${item.quantity}`);
|
||||
throw new Error('Serial count mismatch');
|
||||
}
|
||||
|
||||
|
||||
productData.serial_numbers = validSerials;
|
||||
}
|
||||
|
||||
@ -522,10 +538,10 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
:disabled="item.track_serials"
|
||||
:disabled="item.track_serials && canUseSerials(item)"
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<p v-if="item.track_serials" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p v-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Controlado por seriales
|
||||
</p>
|
||||
</div>
|
||||
@ -546,7 +562,22 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
||||
|
||||
<!-- Selección de seriales (solo si el producto requiere seriales) -->
|
||||
<div v-if="item.inventory_id && item.track_serials" class="col-span-12">
|
||||
<div class="mt-2 p-2 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<!-- Advertencia si la unidad permite decimales -->
|
||||
<div v-if="!canUseSerials(item)" class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<GoogleIcon name="warning" class="text-red-600 dark:text-red-400 text-lg shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-red-900 dark:text-red-100">
|
||||
No se pueden usar números de serie con esta unidad de medida
|
||||
</p>
|
||||
<p class="text-xs text-red-700 dark:text-red-300 mt-1">
|
||||
Este producto usa la unidad <strong>{{ item.unit_of_measure?.name }} ({{ item.unit_of_measure?.abbreviation }})</strong> que permite cantidades decimales.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-2 p-2 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<GoogleIcon name="qr_code_2" class="text-blue-600 dark:text-blue-400 text-lg" />
|
||||
|
||||
@ -256,12 +256,20 @@ onMounted(async () => {
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
v-if="!model.unit_of_measure?.allows_decimals"
|
||||
@click="openSerials(model)"
|
||||
class="text-emerald-600 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-300 transition-colors"
|
||||
title="Ver números de serie"
|
||||
title="Gestionar números de serie"
|
||||
>
|
||||
<GoogleIcon name="qr_code_2" class="text-xl" />
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-gray-400 dark:text-gray-600 cursor-not-allowed"
|
||||
:title="`No disponible: ${model.unit_of_measure.name} (${model.unit_of_measure.abbreviation}) permite decimales`"
|
||||
>
|
||||
<GoogleIcon name="qr_code_2" class="text-xl opacity-30" />
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
49
src/services/whatsappService.js
Normal file
49
src/services/whatsappService.js
Normal file
@ -0,0 +1,49 @@
|
||||
import { api, apiURL } from '@Services/Api';
|
||||
|
||||
/**
|
||||
* Servicio para enviar mensajes por WhatsApp
|
||||
*/
|
||||
const whatsappService = {
|
||||
/**
|
||||
* Enviar factura por WhatsApp
|
||||
* @param {Object} data - Datos de la factura
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async sendInvoice({ phone_number, invoice_number, pdf_url, xml_url, customer_name }) {
|
||||
try {
|
||||
const { data } = await window.axios.post(apiURL('whatsapp/send-invoice'), {
|
||||
phone_number,
|
||||
invoice_number,
|
||||
pdf_url,
|
||||
xml_url,
|
||||
customer_name
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
const errorData = error.response?.data?.data || error.response?.data;
|
||||
console.error('WhatsApp Error Detail:', errorData);
|
||||
throw errorData;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Enviar mensaje genérico por WhatsApp
|
||||
* @param {Object} data - Datos del mensaje
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async sendMessage({ phone_number, message }) {
|
||||
try {
|
||||
const { data } = await window.axios.post(apiURL('whatsapp/send-message'), {
|
||||
phone_number,
|
||||
message
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
const errorData = error.response?.data?.data || error.response?.data;
|
||||
console.error('WhatsApp Error Detail:', errorData);
|
||||
throw errorData;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default whatsappService;
|
||||
@ -72,7 +72,10 @@ const useCart = defineStore('cart', {
|
||||
// Campos para seriales
|
||||
track_serials: product.track_serials || false,
|
||||
serial_numbers: serialConfig?.serialNumbers || [],
|
||||
serial_selection_mode: serialConfig?.selectionMode || null
|
||||
serial_selection_mode: serialConfig?.selectionMode || null,
|
||||
// Campos para unidad de medida
|
||||
unit_of_measure: product.unit_of_measure || null,
|
||||
allows_decimals: product.unit_of_measure?.allows_decimals || false
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -99,7 +102,10 @@ const useCart = defineStore('cart', {
|
||||
tax_rate: parseFloat(product.price?.tax || 16),
|
||||
max_stock: product.stock,
|
||||
track_serials: product.track_serials,
|
||||
serial_numbers: newSerials
|
||||
serial_numbers: newSerials,
|
||||
// Campos para unidad de medida
|
||||
unit_of_measure: product.unit_of_measure || null,
|
||||
allows_decimals: product.unit_of_measure?.allows_decimals || false
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -135,10 +141,14 @@ const useCart = defineStore('cart', {
|
||||
updateQuantity(inventoryId, quantity) {
|
||||
const item = this.items.find(i => i.inventory_id === inventoryId);
|
||||
if (item) {
|
||||
if (quantity <= 0) {
|
||||
// Convertir a número (puede ser decimal)
|
||||
const numQuantity = parseFloat(quantity);
|
||||
|
||||
if (isNaN(numQuantity) || numQuantity <= 0) {
|
||||
this.removeProduct(inventoryId);
|
||||
} else if (quantity <= item.max_stock) {
|
||||
item.quantity = quantity;
|
||||
} else if (numQuantity <= item.max_stock) {
|
||||
// Si NO permite decimales, redondear a entero
|
||||
item.quantity = item.allows_decimals ? numQuantity : Math.floor(numQuantity);
|
||||
} else {
|
||||
window.Notify.warning('No hay suficiente stock disponible');
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user