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:
Juan Felipe Zapata Moreno 2026-02-06 23:22:59 -06:00
parent 04e84f6241
commit 4307d97639
9 changed files with 912 additions and 123 deletions

View File

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

View File

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

View File

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

View File

@ -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 => ({

View File

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

View File

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

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

View File

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

View File

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