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:
Juan Felipe Zapata Moreno 2026-02-10 00:06:42 -06:00
parent cbf8ccb64c
commit 99f190f61b
18 changed files with 1146 additions and 242 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&#10;Ejemplo:&#10;IMEI-123456&#10;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 -->

View File

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

View File

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

View File

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

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

View File

@ -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');
}