Juan Felipe Zapata Moreno 04e84f6241 feat: gestión multiproducto en salidas y traspasos
- Habilita selección múltiple con cantidades en ExitModal y TransferModal.
- Implementa lógica para agregar/quitar productos y calcular totales.
- Agrega validación de selección mínima antes de enviar el formulario.
2026-02-06 16:01:39 -06:00

500 lines
24 KiB
Vue

<script setup>
import { ref, watch, computed } from 'vue';
import { formatCurrency } from '@/utils/formatters';
import { useForm, useApi, apiURL } from '@Services/Api';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Estado */
const products = ref([]);
const warehouses = ref([]);
const loading = ref(false);
const selectedProducts = ref([]);
// Estado para búsqueda de productos
let debounceTimer = null;
const productSearch = ref('');
const searchingProduct = ref(false);
const productNotFound = ref(false);
const productSuggestions = ref([]);
const showProductSuggestions = ref(false);
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
const api = useApi();
/** Formulario */
const form = useForm({
warehouse_id: '',
invoice_reference: '',
notes: '',
products: []
});
/** Computed */
const totalCost = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + (item.quantity * item.unit_cost);
}, 0);
});
const totalQuantity = computed(() => {
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
});
/** Métodos */
const loadData = () => {
loading.value = true;
Promise.all([
api.get(apiURL('almacenes'), {
onSuccess: (data) => {
warehouses.value = data.warehouses?.data || data.data || [];
}
}),
api.get(apiURL('inventario'), {
onSuccess: (data) => {
products.value = data.products?.data || data.data || [];
}
})
]).finally(() => {
loading.value = false;
});
};
const addProduct = () => {
selectedProducts.value.push({
inventory_id: '',
product_name: '',
product_sku: '',
quantity: 1,
unit_cost: 0
});
};
const removeProduct = (index) => {
selectedProducts.value.splice(index, 1);
};
const getProductName = (productId) => {
const product = products.value.find(p => p.id == productId);
return product ? `${product.name} (${product.sku})` : '';
};
/** Métodos de búsqueda de productos */
const onProductInput = (index) => {
currentSearchIndex.value = index;
productNotFound.value = false;
const searchValue = productSearch.value?.trim();
if (!searchValue || searchValue.length < 2) {
productSuggestions.value = [];
showProductSuggestions.value = false;
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
showProductSuggestions.value = true;
searchProduct();
}, 300);
};
const searchProduct = () => {
const searchValue = productSearch.value?.trim();
if (!searchValue) {
productSuggestions.value = [];
showProductSuggestions.value = false;
return;
}
searchingProduct.value = true;
productNotFound.value = false;
api.get(apiURL(`inventario?q=${encodeURIComponent(searchValue)}`), {
onSuccess: (data) => {
const foundProducts = data.products?.data || data.data || data.products || [];
if (foundProducts.length > 0) {
productSuggestions.value = foundProducts;
showProductSuggestions.value = true;
} else {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
}
},
onFail: (data) => {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
window.Notify.error(data.message || 'Error al buscar producto');
},
onError: () => {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
},
onFinish: () => {
searchingProduct.value = false;
}
});
};
const selectProduct = (product) => {
if (currentSearchIndex.value !== null) {
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
}
productSearch.value = '';
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = false;
currentSearchIndex.value = null;
window.Notify.success(`Producto ${product.name} agregado`);
};
const clearProductSearch = (index) => {
if (currentSearchIndex.value === index) {
productSearch.value = '';
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = false;
currentSearchIndex.value = null;
}
};
const createEntry = () => {
// Preparar datos del formulario
form.products = selectedProducts.value.map(item => ({
inventory_id: item.inventory_id,
quantity: Number(item.quantity),
unit_cost: Number(item.unit_cost)
}));
form.post(apiURL('movimientos/entrada'), {
onSuccess: () => {
window.Notify.success('Entrada registrada correctamente');
emit('created');
closeModal();
},
onFail: (data) => {
window.Notify.error(data.message || 'Error al registrar la entrada');
},
onError: () => {
window.Notify.error('Error al registrar la entrada');
}
});
};
const closeModal = () => {
form.reset();
selectedProducts.value = [];
productSearch.value = '';
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = false;
currentSearchIndex.value = null;
emit('close');
};
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown) {
loadData();
if (selectedProducts.value.length === 0) {
addProduct();
}
}
});
// Limpiar productos seleccionados cuando se cambia el almacén
watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
if (newWarehouseId !== oldWarehouseId && oldWarehouseId && selectedProducts.value.length > 0) {
selectedProducts.value = [];
addProduct();
}
});
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30">
<GoogleIcon name="add_circle" class="text-xl text-green-600 dark:text-green-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Registrar Entrada
</h3>
</div>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createEntry" class="space-y-4">
<div class="space-y-4">
<!-- Almacén destino -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ALMACÉN DESTINO
</label>
<select
v-model="form.warehouse_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Seleccionar almacén...</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
{{ wh.name }} ({{ wh.code }})
</option>
</select>
<FormError :message="form.errors?.warehouse_id" />
</div>
<!-- Lista de productos -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
PRODUCTOS
</label>
<button
type="button"
@click="addProduct"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
>
<GoogleIcon name="add" class="text-sm" />
Agregar producto
</button>
</div>
<div class="space-y-3">
<div
v-for="(item, index) in selectedProducts"
:key="index"
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
>
<div class="grid grid-cols-12 gap-3 items-start">
<!-- Producto -->
<div class="col-span-12 sm:col-span-5">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Producto
</label>
<!-- Si ya se seleccionó un producto -->
<div v-if="item.inventory_id && item.product_name" class="flex items-center gap-2 px-2 py-1.5 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded text-sm">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ item.product_name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ item.product_sku }}</p>
</div>
<button
type="button"
@click="item.inventory_id = ''; item.product_name = ''; item.product_sku = ''"
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<GoogleIcon name="close" class="text-sm" />
</button>
</div>
<!-- Input de búsqueda -->
<div v-else class="relative">
<input
v-model="productSearch"
@input="onProductInput(index)"
@focus="() => { currentSearchIndex = index; productSuggestions.length > 0 && (showProductSuggestions = true); }"
type="text"
placeholder="Buscar por código de barras o nombre..."
class="w-full px-2 py-1.5 pr-8 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"
:class="{
'border-red-500 focus:ring-red-500 focus:border-red-500': productNotFound && currentSearchIndex === index
}"
:disabled="searchingProduct"
/>
<div v-if="searchingProduct && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
<GoogleIcon name="hourglass_empty" class="text-sm text-gray-400 animate-spin" />
</div>
<div v-else-if="productSearch && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
<GoogleIcon name="search" class="text-sm text-gray-400" />
</div>
<!-- Dropdown de sugerencias -->
<div
v-if="showProductSuggestions && productSuggestions.length > 0 && currentSearchIndex === index"
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<button
v-for="product in productSuggestions"
:key="product.id"
type="button"
@click="selectProduct(product)"
class="w-full flex items-center gap-2 px-3 py-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ product.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
SKU: {{ product.sku }}
</p>
</div>
</button>
</div>
<!-- Error de producto no encontrado -->
<div v-if="productNotFound && currentSearchIndex === index" class="absolute z-50 w-full mt-1">
<div class="flex items-start gap-1 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2">
<GoogleIcon name="error" class="text-red-600 dark:text-red-400 text-sm shrink-0" />
<p class="text-xs text-red-800 dark:text-red-300">
Producto no encontrado
</p>
</div>
</div>
</div>
</div>
<!-- Cantidad -->
<div class="col-span-5 sm:col-span-3">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Cantidad
</label>
<input
v-model="item.quantity"
type="number"
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"
/>
</div>
<!-- Costo unitario -->
<div class="col-span-5 sm:col-span-3">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Costo unit.
</label>
<input
v-model="item.unit_cost"
type="number"
min="0"
step="0.01"
placeholder="0.00"
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"
/>
</div>
<!-- Botón eliminar -->
<div class="col-span-2 sm:col-span-1">
<label class="block text-xs font-medium text-transparent mb-1">.</label>
<button
type="button"
@click="removeProduct(index)"
:disabled="selectedProducts.length === 1"
class="w-full px-2 py-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:title="selectedProducts.length === 1 ? 'Debe haber al menos un producto' : 'Eliminar producto'"
>
<GoogleIcon name="delete" class="text-lg" />
</button>
</div>
</div>
<!-- Subtotal del producto -->
<div v-if="item.quantity && item.unit_cost" class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-600 dark:text-gray-400">
Subtotal:
<span class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(item.quantity * item.unit_cost) }}
</span>
</p>
</div>
</div>
</div>
<!-- Resumen total -->
<div v-if="selectedProducts.length > 0" class="mt-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">
Total de productos: {{ selectedProducts.length }}
</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
Cantidad total: {{ totalQuantity }}
</span>
<span class="font-bold text-indigo-900 dark:text-indigo-100">
Costo total: {{ formatCurrency(totalCost) }}
</span>
</div>
</div>
<FormError :message="form.errors?.products" />
</div>
<!-- Referencia de factura -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
REFERENCIA DE FACTURA
</label>
<FormInput
v-model="form.invoice_reference"
type="text"
placeholder="Ej: FAC-2026-001"
required
/>
<FormError :message="form.errors?.invoice_reference" />
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
</label>
<textarea
v-model="form.notes"
rows="2"
placeholder="Ej: Compra de proveedor X"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
></textarea>
<FormError :message="form.errors?.notes" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Cancelar
</button>
<button
type="submit"
:disabled="form.processing || selectedProducts.length === 0"
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<GoogleIcon name="add_circle" class="text-lg" />
<span v-if="form.processing">Registrando...</span>
<span v-else>Registrar Entrada</span>
</button>
</div>
</form>
</div>
</Modal>
</template>