feat: exportación de kardex y mejoras en búsqueda
- Implementa KardexModal para generar reportes de inventario. - Optimiza búsqueda en modales con debounce y visualización de SKU. - Agrega cálculo de costos totales y stock disponible por almacén.
This commit is contained in:
parent
04e84f6241
commit
4307d97639
@ -23,7 +23,6 @@ const form = useForm({
|
|||||||
sku: '',
|
sku: '',
|
||||||
barcode: '',
|
barcode: '',
|
||||||
category_id: '',
|
category_id: '',
|
||||||
cost: 0,
|
|
||||||
retail_price: 0,
|
retail_price: 0,
|
||||||
tax: 16
|
tax: 16
|
||||||
});
|
});
|
||||||
@ -157,22 +156,6 @@ watch(() => props.show, (newValue) => {
|
|||||||
<FormError :message="form.errors?.category_id" />
|
<FormError :message="form.errors?.category_id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Costo -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
||||||
COSTO
|
|
||||||
</label>
|
|
||||||
<FormInput
|
|
||||||
v-model.number="form.cost"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.cost" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Precio de Venta -->
|
<!-- Precio de Venta -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
|||||||
@ -28,7 +28,6 @@ const form = useForm({
|
|||||||
sku: '',
|
sku: '',
|
||||||
barcode: '',
|
barcode: '',
|
||||||
category_id: '',
|
category_id: '',
|
||||||
cost: 0,
|
|
||||||
retail_price: 0,
|
retail_price: 0,
|
||||||
tax: 16
|
tax: 16
|
||||||
});
|
});
|
||||||
@ -179,22 +178,6 @@ watch(() => props.show, (newValue) => {
|
|||||||
<FormError :message="form.errors?.category_id" />
|
<FormError :message="form.errors?.category_id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Costo -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
||||||
COSTO
|
|
||||||
</label>
|
|
||||||
<FormInput
|
|
||||||
v-model.number="form.cost"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.cost" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Precio de Venta -->
|
<!-- Precio de Venta -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
|||||||
@ -28,6 +28,9 @@ const showImportModal = ref(false);
|
|||||||
const editingProduct = ref(null);
|
const editingProduct = ref(null);
|
||||||
const deletingProduct = ref(null);
|
const deletingProduct = ref(null);
|
||||||
const isExporting = ref(false);
|
const isExporting = ref(false);
|
||||||
|
const fecha_inicio = ref('');
|
||||||
|
const fecha_fin = ref('');
|
||||||
|
const currentSearch = ref('');
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const searcher = useSearcher({
|
const searcher = useSearcher({
|
||||||
@ -96,15 +99,11 @@ const closeImportModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onProductSaved = () => {
|
const onProductSaved = () => {
|
||||||
searcher.search('', {
|
applyFilters();
|
||||||
category_id: selectedCategory.value || ''
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onProductsImported = () => {
|
const onProductsImported = () => {
|
||||||
searcher.search('', {
|
applyFilters();
|
||||||
category_id: selectedCategory.value || ''
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSerials = (product) => {
|
const openSerials = (product) => {
|
||||||
@ -124,9 +123,7 @@ const confirmDelete = async (id) => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
Notify.success('Producto eliminado exitosamente');
|
Notify.success('Producto eliminado exitosamente');
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
searcher.search('', {
|
applyFilters();
|
||||||
category_id: selectedCategory.value || ''
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
Notify.error('Error al eliminar el producto');
|
Notify.error('Error al eliminar el producto');
|
||||||
}
|
}
|
||||||
@ -136,10 +133,24 @@ const confirmDelete = async (id) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
const filters = {
|
||||||
|
category_id: selectedCategory.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fecha_inicio.value) {
|
||||||
|
filters.fecha_inicio = fecha_inicio.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fecha_fin.value) {
|
||||||
|
filters.fecha_fin = fecha_fin.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
searcher.search('', filters);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCategoryChange = () => {
|
const handleCategoryChange = () => {
|
||||||
searcher.search('', {
|
applyFilters();
|
||||||
category_id: selectedCategory.value || ''
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportReport = async () => {
|
const exportReport = async () => {
|
||||||
@ -148,16 +159,38 @@ const exportReport = async () => {
|
|||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
category_id: selectedCategory.value || null,
|
category_id: selectedCategory.value || null,
|
||||||
// with_serials_only: true, // Opcional: solo productos con seguimiento de seriales
|
|
||||||
// low_stock_threshold: 10 // Opcional: solo productos con stock bajo o igual al umbral
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (fecha_inicio.value) {
|
||||||
|
filters.fecha_inicio = fecha_inicio.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fecha_fin.value) {
|
||||||
|
filters.fecha_fin = fecha_fin.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSearch.value) {
|
||||||
|
filters.q = currentSearch.value;
|
||||||
|
}
|
||||||
|
|
||||||
await reportService.exportInventoryToExcel(filters);
|
await reportService.exportInventoryToExcel(filters);
|
||||||
|
|
||||||
Notify.success('Reporte exportado exitosamente');
|
Notify.success('Reporte exportado exitosamente');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error al exportar:', error);
|
console.error('Error al exportar:', error);
|
||||||
Notify.error('Error al exportar el reporte');
|
|
||||||
|
// Mostrar errores de validación específicos del backend
|
||||||
|
if (error.response?.data?.errors) {
|
||||||
|
const errors = error.response.data.errors;
|
||||||
|
const errorMessages = Object.values(errors).flat();
|
||||||
|
errorMessages.forEach(msg => Notify.error(msg));
|
||||||
|
} else if (error.response?.data?.message) {
|
||||||
|
Notify.error(error.response.data.message);
|
||||||
|
} else if (error.message) {
|
||||||
|
Notify.error(error.message);
|
||||||
|
} else {
|
||||||
|
Notify.error('Error al exportar el reporte');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isExporting.value = false;
|
isExporting.value = false;
|
||||||
}
|
}
|
||||||
@ -166,9 +199,7 @@ const exportReport = async () => {
|
|||||||
/** Ciclos */
|
/** Ciclos */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCategories();
|
loadCategories();
|
||||||
searcher.search('', {
|
applyFilters();
|
||||||
category_id: selectedCategory.value || ''
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -177,7 +208,13 @@ onMounted(() => {
|
|||||||
<SearcherHead
|
<SearcherHead
|
||||||
:title="$t('inventory.title')"
|
:title="$t('inventory.title')"
|
||||||
placeholder="Buscar por nombre o SKU..."
|
placeholder="Buscar por nombre o SKU..."
|
||||||
@search="(x) => searcher.search(x, { category_id: selectedCategory || '' })"
|
@search="(x) => {
|
||||||
|
currentSearch = x;
|
||||||
|
const filters = { category_id: selectedCategory || '' };
|
||||||
|
if (fecha_inicio.value) filters.fecha_inicio = fecha_inicio.value;
|
||||||
|
if (fecha_fin.value) filters.fecha_fin = fecha_fin.value;
|
||||||
|
searcher.search(x, filters);
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex items-center gap-2 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
@ -209,18 +246,46 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- Filtros -->
|
<!-- Filtros -->
|
||||||
<div class="pt-4 pb-2">
|
<div class="pt-4 pb-2">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-end gap-4">
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Filtrar por categoría:</label>
|
<div>
|
||||||
<select
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Filtrar por categoría:</label>
|
||||||
v-model="selectedCategory"
|
<select
|
||||||
@change="handleCategoryChange"
|
v-model="selectedCategory"
|
||||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
@change="handleCategoryChange"
|
||||||
>
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
<option value="">Todas las categorías</option>
|
>
|
||||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
<option value="">Todas las categorías</option>
|
||||||
{{ category.name }}
|
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||||
</option>
|
{{ category.name }}
|
||||||
</select>
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Desde</label>
|
||||||
|
<input
|
||||||
|
v-model="fecha_inicio"
|
||||||
|
@change="applyFilters"
|
||||||
|
type="date"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Hasta</label>
|
||||||
|
<input
|
||||||
|
v-model="fecha_fin"
|
||||||
|
@change="applyFilters"
|
||||||
|
type="date"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
@click="selectedCategory = ''; fecha_inicio = ''; fecha_fin = ''; currentSearch = ''; applyFilters();"
|
||||||
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors bg-gray-100 dark:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -86,11 +86,6 @@ const removeProduct = (index) => {
|
|||||||
selectedProducts.value.splice(index, 1);
|
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 */
|
/** Métodos de búsqueda de productos */
|
||||||
const onProductInput = (index) => {
|
const onProductInput = (index) => {
|
||||||
currentSearchIndex.value = index;
|
currentSearchIndex.value = index;
|
||||||
@ -166,16 +161,6 @@ const selectProduct = (product) => {
|
|||||||
window.Notify.success(`Producto ${product.name} agregado`);
|
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 = () => {
|
const createEntry = () => {
|
||||||
// Preparar datos del formulario
|
// Preparar datos del formulario
|
||||||
form.products = selectedProducts.value.map(item => ({
|
form.products = selectedProducts.value.map(item => ({
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useForm, useApi, apiURL } from '@Services/Api';
|
import { useForm, useApi, apiURL } from '@Services/Api';
|
||||||
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
|
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
import FormInput from '@Holos/Form/Input.vue';
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
@ -20,9 +21,19 @@ const products = ref([]);
|
|||||||
const warehouses = ref([]);
|
const warehouses = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const selectedProducts = ref([]);
|
const selectedProducts = ref([]);
|
||||||
|
const warehouseFromStock = ref(null);
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
warehouse_id: '',
|
warehouse_id: '',
|
||||||
@ -36,6 +47,12 @@ const totalQuantity = computed(() => {
|
|||||||
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
|
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalCost = computed(() => {
|
||||||
|
return selectedProducts.value.reduce((sum, item) => {
|
||||||
|
return sum + (item.quantity * (item.unit_cost || 0));
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const loadData = () => {
|
const loadData = () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@ -53,6 +70,7 @@ const loadData = () => {
|
|||||||
const loadProducts = (warehouseId) => {
|
const loadProducts = (warehouseId) => {
|
||||||
if (!warehouseId) {
|
if (!warehouseId) {
|
||||||
products.value = [];
|
products.value = [];
|
||||||
|
warehouseFromStock.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +78,9 @@ const loadProducts = (warehouseId) => {
|
|||||||
|
|
||||||
api.get(apiURL(`inventario/almacen/${warehouseId}`), {
|
api.get(apiURL(`inventario/almacen/${warehouseId}`), {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
products.value = data.products || [];
|
const productList = data.products || [];
|
||||||
|
products.value = productList;
|
||||||
|
warehouseFromStock.value = productList.length;
|
||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@ -71,7 +91,10 @@ const loadProducts = (warehouseId) => {
|
|||||||
const addProduct = () => {
|
const addProduct = () => {
|
||||||
selectedProducts.value.push({
|
selectedProducts.value.push({
|
||||||
inventory_id: '',
|
inventory_id: '',
|
||||||
quantity: 1
|
product_name: '',
|
||||||
|
product_sku: '',
|
||||||
|
quantity: 1,
|
||||||
|
unit_cost: 0
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -79,6 +102,90 @@ const removeProduct = (index) => {
|
|||||||
selectedProducts.value.splice(index, 1);
|
selectedProducts.value.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que haya un almacén de origen seleccionado
|
||||||
|
if (!form.warehouse_id) {
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = true;
|
||||||
|
window.Notify.warning('Primero selecciona el almacén de origen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchingProduct.value = true;
|
||||||
|
productNotFound.value = false;
|
||||||
|
|
||||||
|
api.get(apiURL(`inventario/almacen/${form.warehouse_id}?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 createExit = () => {
|
const createExit = () => {
|
||||||
// Preparar datos del formulario
|
// Preparar datos del formulario
|
||||||
form.products = selectedProducts.value.map(item => ({
|
form.products = selectedProducts.value.map(item => ({
|
||||||
@ -104,6 +211,11 @@ const createExit = () => {
|
|||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
selectedProducts.value = [];
|
selectedProducts.value = [];
|
||||||
|
productSearch.value = '';
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = false;
|
||||||
|
currentSearchIndex.value = null;
|
||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -171,23 +283,31 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
{{ wh.name }} ({{ wh.code }})
|
{{ wh.name }} ({{ wh.code }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p v-if="warehouseFromStock !== null" class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span v-if="warehouseFromStock > 0" class="text-green-600 dark:text-green-400">
|
||||||
|
✓ {{ warehouseFromStock }} producto(s) en inventario
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-amber-600 dark:text-amber-400">
|
||||||
|
⚠ Sin productos en inventario
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<FormError :message="form.errors?.warehouse_id" />
|
<FormError :message="form.errors?.warehouse_id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lista de productos -->
|
<!-- Lista de productos -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
|
||||||
PRODUCTOS
|
PRODUCTOS
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="addProduct"
|
@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"
|
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" />
|
<GoogleIcon name="add" class="text-sm" />
|
||||||
Agregar producto
|
Agregar producto
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@ -198,23 +318,82 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
>
|
>
|
||||||
<div class="grid grid-cols-12 gap-3 items-start">
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
<!-- Producto -->
|
<!-- Producto -->
|
||||||
<div class="col-span-12 sm:col-span-9">
|
<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">
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
Producto
|
Producto
|
||||||
</label>
|
</label>
|
||||||
<select
|
<!-- Si ya se seleccionó un producto -->
|
||||||
v-model="item.inventory_id"
|
<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">
|
||||||
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 class="flex-1 min-w-0">
|
||||||
>
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
<option value="">Seleccionar...</option>
|
{{ item.product_name }}
|
||||||
<option v-for="product in products" :key="product.id" :value="product.id">
|
</p>
|
||||||
{{ product.name }} ({{ product.sku }})
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ item.product_sku }}</p>
|
||||||
</option>
|
</div>
|
||||||
</select>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Cantidad -->
|
<!-- Cantidad -->
|
||||||
<div class="col-span-10 sm:col-span-2">
|
<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">
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
Cantidad
|
Cantidad
|
||||||
</label>
|
</label>
|
||||||
@ -228,6 +407,21 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Botón eliminar -->
|
||||||
<div class="col-span-2 sm:col-span-1">
|
<div class="col-span-2 sm:col-span-1">
|
||||||
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
||||||
@ -242,6 +436,16 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -251,9 +455,12 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||||
Total de productos: {{ selectedProducts.length }}
|
Total de productos: {{ selectedProducts.length }}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-bold text-red-900 dark:text-red-100">
|
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||||
Cantidad total: {{ totalQuantity }}
|
Cantidad total: {{ totalQuantity }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="font-bold text-red-900 dark:text-red-100">
|
||||||
|
Costo total: {{ formatCurrency(totalCost) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import EntryModal from './EntryModal.vue';
|
|||||||
import ExitModal from './ExitModal.vue';
|
import ExitModal from './ExitModal.vue';
|
||||||
import TransferModal from './TransferModal.vue';
|
import TransferModal from './TransferModal.vue';
|
||||||
import DetailModal from './DetailModal.vue';
|
import DetailModal from './DetailModal.vue';
|
||||||
|
import KardexModal from './KardexModal.vue';
|
||||||
|
|
||||||
/** Estado */
|
/** Estado */
|
||||||
const movements = ref({});
|
const movements = ref({});
|
||||||
@ -27,6 +28,9 @@ const showDetailModal = ref(false);
|
|||||||
const selectedMovementId = ref(null);
|
const selectedMovementId = ref(null);
|
||||||
const selectedMovement = ref(null);
|
const selectedMovement = ref(null);
|
||||||
|
|
||||||
|
/** Kardex */
|
||||||
|
const showKardexModal = ref(false);
|
||||||
|
|
||||||
/** Filtros computados */
|
/** Filtros computados */
|
||||||
const filters = computed(() => {
|
const filters = computed(() => {
|
||||||
const f = {};
|
const f = {};
|
||||||
@ -172,6 +176,13 @@ onMounted(() => {
|
|||||||
@search="(x) => searcher.search(x, filters)"
|
@search="(x) => searcher.search(x, filters)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||||
|
@click="showKardexModal = true"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="download" class="text-lg" />
|
||||||
|
Kardex
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="can('create')"
|
v-if="can('create')"
|
||||||
class="flex items-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
class="flex items-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||||
@ -371,5 +382,11 @@ onMounted(() => {
|
|||||||
:movement-data="selectedMovement"
|
:movement-data="selectedMovement"
|
||||||
@close="closeDetailModal"
|
@close="closeDetailModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Modal Kardex -->
|
||||||
|
<KardexModal
|
||||||
|
:show="showKardexModal"
|
||||||
|
@close="showKardexModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
256
src/pages/POS/Movements/KardexModal.vue
Normal file
256
src/pages/POS/Movements/KardexModal.vue
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useApi, apiURL } from '@Services/Api';
|
||||||
|
import reportService from '@Services/reportService';
|
||||||
|
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const api = useApi();
|
||||||
|
const warehouses = ref([]);
|
||||||
|
const isExporting = ref(false);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
inventory_id: '',
|
||||||
|
fecha_inicio: '',
|
||||||
|
fecha_fin: '',
|
||||||
|
warehouse_id: '',
|
||||||
|
movement_type: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Búsqueda de producto
|
||||||
|
let debounceTimer = null;
|
||||||
|
const productSearch = ref('');
|
||||||
|
const productSuggestions = ref([]);
|
||||||
|
const selectedProduct = ref(null);
|
||||||
|
const showSuggestions = ref(false);
|
||||||
|
|
||||||
|
/** Tipos de movimiento */
|
||||||
|
const movementTypes = [
|
||||||
|
{ value: '', label: 'Todos' },
|
||||||
|
{ value: 'entry', label: 'Entrada' },
|
||||||
|
{ value: 'exit', label: 'Salida' },
|
||||||
|
{ value: 'transfer', label: 'Traspaso' },
|
||||||
|
/* { value: 'sale', label: 'Venta' },
|
||||||
|
{ value: 'return', label: 'Devolución' }, */
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const loadWarehouses = () => {
|
||||||
|
api.get(apiURL('almacenes'), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
warehouses.value = data.warehouses?.data || data.data || [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchProduct = () => {
|
||||||
|
const q = productSearch.value.trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showSuggestions.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
api.get(apiURL(`inventario?q=${encodeURIComponent(q)}`), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
productSuggestions.value = data.products?.data || data.data || data.products || [];
|
||||||
|
showSuggestions.value = productSuggestions.value.length > 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectProduct = (product) => {
|
||||||
|
selectedProduct.value = product;
|
||||||
|
form.value.inventory_id = product.id;
|
||||||
|
productSearch.value = `${product.name} (${product.sku})`;
|
||||||
|
showSuggestions.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearProduct = () => {
|
||||||
|
selectedProduct.value = null;
|
||||||
|
form.value.inventory_id = '';
|
||||||
|
productSearch.value = '';
|
||||||
|
productSuggestions.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportKardex = async () => {
|
||||||
|
try {
|
||||||
|
isExporting.value = true;
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
inventory_id: form.value.inventory_id,
|
||||||
|
fecha_inicio: form.value.fecha_inicio,
|
||||||
|
fecha_fin: form.value.fecha_fin,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.value.warehouse_id) {
|
||||||
|
filters.warehouse_id = form.value.warehouse_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.value.movement_type) {
|
||||||
|
filters.movement_type = form.value.movement_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reportService.exportKardexToExcel(filters);
|
||||||
|
Notify.success('Kardex exportado exitosamente');
|
||||||
|
emit('close');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message) {
|
||||||
|
Notify.error(error.message);
|
||||||
|
} else {
|
||||||
|
Notify.error('Error al exportar el kardex');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isExporting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.value = { inventory_id: '', fecha_inicio: '', fecha_fin: '', warehouse_id: '', movement_type: '' };
|
||||||
|
productSearch.value = '';
|
||||||
|
selectedProduct.value = null;
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showSuggestions.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Observadores */
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
resetForm();
|
||||||
|
loadWarehouses();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="lg" @close="emit('close')">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<GoogleIcon name="assignment" class="text-2xl text-emerald-600" />
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">Exportar Kardex</h3>
|
||||||
|
</div>
|
||||||
|
<button @click="emit('close')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<GoogleIcon name="close" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Producto (requerido) -->
|
||||||
|
<div class="relative">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Producto *</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="productSearch"
|
||||||
|
@input="searchProduct"
|
||||||
|
@focus="showSuggestions = productSuggestions.length > 0"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar producto por nombre o SKU..."
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
||||||
|
:class="{ 'pr-8': selectedProduct }"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="selectedProduct"
|
||||||
|
@click="clearProduct"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-base" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedProduct" class="mt-1 text-xs text-green-600 dark:text-green-400">
|
||||||
|
Seleccionado: {{ selectedProduct.name }} ({{ selectedProduct.sku }})
|
||||||
|
</div>
|
||||||
|
<!-- Sugerencias -->
|
||||||
|
<div
|
||||||
|
v-if="showSuggestions"
|
||||||
|
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="product in productSuggestions"
|
||||||
|
:key="product.id"
|
||||||
|
@click="selectProduct(product)"
|
||||||
|
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm"
|
||||||
|
>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100">{{ product.name }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">SKU: {{ product.sku }}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fechas (requeridas) -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Desde *</label>
|
||||||
|
<input
|
||||||
|
v-model="form.fecha_inicio"
|
||||||
|
type="date"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hasta *</label>
|
||||||
|
<input
|
||||||
|
v-model="form.fecha_fin"
|
||||||
|
type="date"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Almacén (opcional) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Almacén (opcional)</label>
|
||||||
|
<select
|
||||||
|
v-model="form.warehouse_id"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="">Todos los almacenes</option>
|
||||||
|
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">{{ wh.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tipo de movimiento (opcional) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo de movimiento (opcional)</label>
|
||||||
|
<select
|
||||||
|
v-model="form.movement_type"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option v-for="type in movementTypes" :key="type.value" :value="type.value">{{ type.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botones -->
|
||||||
|
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
@click="emit('close')"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="exportKardex"
|
||||||
|
:disabled="!form.inventory_id || !form.fecha_inicio || !form.fecha_fin || isExporting"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<GoogleIcon :name="isExporting ? 'hourglass_empty' : 'download'" class="text-lg" />
|
||||||
|
{{ isExporting ? 'Exportando...' : 'Exportar Kardex' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useForm, useApi, apiURL } from '@Services/Api';
|
import { useForm, useApi, apiURL } from '@Services/Api';
|
||||||
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
|
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
import FormInput from '@Holos/Form/Input.vue';
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
@ -20,9 +21,20 @@ const products = ref([]);
|
|||||||
const warehouses = ref([]);
|
const warehouses = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const selectedProducts = ref([]);
|
const selectedProducts = ref([]);
|
||||||
|
const warehouseFromStock = ref(null);
|
||||||
|
const warehouseToStock = ref(null);
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
warehouse_from_id: '',
|
warehouse_from_id: '',
|
||||||
@ -55,9 +67,14 @@ const loadData = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadProducts = (warehouseId) => {
|
const loadProducts = (warehouseId, isOrigin = true) => {
|
||||||
if (!warehouseId) {
|
if (!warehouseId) {
|
||||||
products.value = [];
|
products.value = [];
|
||||||
|
if (isOrigin) {
|
||||||
|
warehouseFromStock.value = null;
|
||||||
|
} else {
|
||||||
|
warehouseToStock.value = null;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +82,13 @@ const loadProducts = (warehouseId) => {
|
|||||||
|
|
||||||
api.get(apiURL(`inventario/almacen/${warehouseId}`), {
|
api.get(apiURL(`inventario/almacen/${warehouseId}`), {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
products.value = data.products || [];
|
const productList = data.products || [];
|
||||||
|
if (isOrigin) {
|
||||||
|
products.value = productList;
|
||||||
|
warehouseFromStock.value = productList.length;
|
||||||
|
} else {
|
||||||
|
warehouseToStock.value = productList.length;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@ -84,6 +107,90 @@ const removeProduct = (index) => {
|
|||||||
selectedProducts.value.splice(index, 1);
|
selectedProducts.value.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que haya un almacén de origen seleccionado
|
||||||
|
if (!form.warehouse_from_id) {
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = true;
|
||||||
|
window.Notify.warning('Primero selecciona el almacén de origen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchingProduct.value = true;
|
||||||
|
productNotFound.value = false;
|
||||||
|
|
||||||
|
api.get(apiURL(`inventario/almacen/${form.warehouse_from_id}?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 createTransfer = () => {
|
const createTransfer = () => {
|
||||||
// Preparar datos del formulario
|
// Preparar datos del formulario
|
||||||
form.products = selectedProducts.value.map(item => ({
|
form.products = selectedProducts.value.map(item => ({
|
||||||
@ -109,6 +216,11 @@ const createTransfer = () => {
|
|||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
selectedProducts.value = [];
|
selectedProducts.value = [];
|
||||||
|
productSearch.value = '';
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = false;
|
||||||
|
currentSearchIndex.value = null;
|
||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -130,7 +242,7 @@ watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
|
|
||||||
// Cargar productos del almacén de origen
|
// Cargar productos del almacén de origen
|
||||||
if (newWarehouseId !== oldWarehouseId) {
|
if (newWarehouseId !== oldWarehouseId) {
|
||||||
loadProducts(newWarehouseId);
|
loadProducts(newWarehouseId, true);
|
||||||
|
|
||||||
// Limpiar productos seleccionados si había alguno y se cambió el almacén
|
// Limpiar productos seleccionados si había alguno y se cambió el almacén
|
||||||
if (oldWarehouseId && selectedProducts.value.length > 0) {
|
if (oldWarehouseId && selectedProducts.value.length > 0) {
|
||||||
@ -139,6 +251,13 @@ watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cargar stock del almacén destino cuando cambia
|
||||||
|
watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
||||||
|
if (newWarehouseId !== oldWarehouseId) {
|
||||||
|
loadProducts(newWarehouseId, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -183,6 +302,14 @@ watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
{{ wh.name }} ({{ wh.code }})
|
{{ wh.name }} ({{ wh.code }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p v-if="warehouseFromStock !== null" class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span v-if="warehouseFromStock > 0" class="text-green-600 dark:text-green-400">
|
||||||
|
✓ {{ warehouseFromStock }} producto(s) en inventario
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-amber-600 dark:text-amber-400">
|
||||||
|
⚠ Sin productos en inventario
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<FormError :message="form.errors?.warehouse_from_id" />
|
<FormError :message="form.errors?.warehouse_from_id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -200,6 +327,14 @@ watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
{{ wh.name }} ({{ wh.code }})
|
{{ wh.name }} ({{ wh.code }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p v-if="warehouseToStock !== null" class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span v-if="warehouseToStock > 0" class="text-blue-600 dark:text-blue-400">
|
||||||
|
ℹ {{ warehouseToStock }} producto(s) en inventario
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-500 dark:text-gray-500">
|
||||||
|
Inventario vacío
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<FormError :message="form.errors?.warehouse_to_id" />
|
<FormError :message="form.errors?.warehouse_to_id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -211,13 +346,13 @@ watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
PRODUCTOS
|
PRODUCTOS
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="addProduct"
|
@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"
|
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" />
|
<GoogleIcon name="add" class="text-sm" />
|
||||||
Agregar producto
|
Agregar producto
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@ -228,23 +363,82 @@ watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
>
|
>
|
||||||
<div class="grid grid-cols-12 gap-3 items-start">
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
<!-- Producto -->
|
<!-- Producto -->
|
||||||
<div class="col-span-12 sm:col-span-9">
|
<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">
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
Producto
|
Producto
|
||||||
</label>
|
</label>
|
||||||
<select
|
<!-- Si ya se seleccionó un producto -->
|
||||||
v-model="item.inventory_id"
|
<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">
|
||||||
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 class="flex-1 min-w-0">
|
||||||
>
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
<option value="">Seleccionar...</option>
|
{{ item.product_name }}
|
||||||
<option v-for="product in products" :key="product.id" :value="product.id">
|
</p>
|
||||||
{{ product.name }} ({{ product.sku }})
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ item.product_sku }}</p>
|
||||||
</option>
|
</div>
|
||||||
</select>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Cantidad -->
|
<!-- Cantidad -->
|
||||||
<div class="col-span-10 sm:col-span-2">
|
<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">
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
Cantidad
|
Cantidad
|
||||||
</label>
|
</label>
|
||||||
@ -258,6 +452,21 @@ watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Botón eliminar -->
|
||||||
<div class="col-span-2 sm:col-span-1">
|
<div class="col-span-2 sm:col-span-1">
|
||||||
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
||||||
@ -272,18 +481,31 @@ watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resumen total -->
|
<!-- Resumen total -->
|
||||||
<div v-if="selectedProducts.length > 0" class="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
<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">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||||
Total de productos: {{ selectedProducts.length }}
|
Total de productos: {{ selectedProducts.length }}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-bold text-blue-900 dark:text-blue-100">
|
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||||
Cantidad total: {{ totalQuantity }}
|
Cantidad total: {{ totalQuantity }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="font-bold text-indigo-900 dark:text-indigo-100">
|
||||||
|
Costo total: {{ formatCurrency(totalCost) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -74,6 +74,9 @@ const reportService = {
|
|||||||
if (filters.category_id) params.push(`category_id=${filters.category_id}`);
|
if (filters.category_id) params.push(`category_id=${filters.category_id}`);
|
||||||
if (filters.with_serials_only !== undefined) params.push(`with_serials_only=${filters.with_serials_only ? 'true' : 'false'}`);
|
if (filters.with_serials_only !== undefined) params.push(`with_serials_only=${filters.with_serials_only ? 'true' : 'false'}`);
|
||||||
if (filters.low_stock_threshold) params.push(`low_stock_threshold=${filters.low_stock_threshold}`);
|
if (filters.low_stock_threshold) params.push(`low_stock_threshold=${filters.low_stock_threshold}`);
|
||||||
|
if (filters.fecha_inicio) params.push(`fecha_inicio=${encodeURIComponent(filters.fecha_inicio)}`);
|
||||||
|
if (filters.fecha_fin) params.push(`fecha_fin=${encodeURIComponent(filters.fecha_fin)}`);
|
||||||
|
if (filters.q) params.push(`q=${encodeURIComponent(filters.q)}`);
|
||||||
|
|
||||||
if (params.length > 0) {
|
if (params.length > 0) {
|
||||||
url += `?${params.join('&')}`;
|
url += `?${params.join('&')}`;
|
||||||
@ -126,6 +129,74 @@ const reportService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports kardex report to Excel file.
|
||||||
|
* @param {Object} filters - Filters for the export.
|
||||||
|
* @param {number} filters.inventory_id - Product ID (required).
|
||||||
|
* @param {string} filters.fecha_inicio - Start date YYYY-MM-DD (required).
|
||||||
|
* @param {string} filters.fecha_fin - End date YYYY-MM-DD (required).
|
||||||
|
* @param {number|null} filters.warehouse_id - Filter by warehouse.
|
||||||
|
* @param {string|null} filters.movement_type - Filter by type: entry, exit, transfer, sale, return.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async exportKardexToExcel(filters = {}) {
|
||||||
|
try {
|
||||||
|
let url = apiURL('reports/kardex/excel');
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (filters.inventory_id) params.push(`inventory_id=${filters.inventory_id}`);
|
||||||
|
if (filters.fecha_inicio) params.push(`fecha_inicio=${encodeURIComponent(filters.fecha_inicio)}`);
|
||||||
|
if (filters.fecha_fin) params.push(`fecha_fin=${encodeURIComponent(filters.fecha_fin)}`);
|
||||||
|
if (filters.warehouse_id) params.push(`warehouse_id=${filters.warehouse_id}`);
|
||||||
|
if (filters.movement_type) params.push(`movement_type=${encodeURIComponent(filters.movement_type)}`);
|
||||||
|
|
||||||
|
if (params.length > 0) {
|
||||||
|
url += `?${params.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await window.axios.get(url, {
|
||||||
|
responseType: 'blob',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([response.data], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.toISOString().split('T')[0];
|
||||||
|
const filename = `kardex_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al exportar kardex:', error);
|
||||||
|
|
||||||
|
if (error.response && error.response.data instanceof Blob) {
|
||||||
|
const text = await error.response.data.text();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
return Promise.reject(new Error(json.message || 'Error al exportar el kardex'));
|
||||||
|
} catch {
|
||||||
|
return Promise.reject(new Error('Error al exportar el kardex'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user