- 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.
500 lines
24 KiB
Vue
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>
|