495 lines
20 KiB
Vue
495 lines
20 KiB
Vue
<script setup>
|
|
import { onMounted, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useSearcher, apiURL } from '@Services/Api';
|
|
import { formatCurrency } from '@/utils/formatters';
|
|
import { can } from './Module.js';
|
|
import reportService from '@Services/reportService';
|
|
|
|
const router = useRouter();
|
|
|
|
import SearcherHead from '@Holos/Searcher.vue';
|
|
import Table from '@Holos/Table.vue';
|
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
|
import CreateModal from './CreateModal.vue';
|
|
import EditModal from './EditModal.vue';
|
|
import DeleteModal from './DeleteModal.vue';
|
|
import ImportModal from './ImportModal.vue';
|
|
|
|
/** Estado */
|
|
const models = ref([]);
|
|
const totalInventoryValue = ref(0);
|
|
const categories = ref([]);
|
|
const selectedCategory = ref('');
|
|
const showCreateModal = ref(false);
|
|
const showEditModal = ref(false);
|
|
const showDeleteModal = ref(false);
|
|
const showImportModal = ref(false);
|
|
const editingProduct = ref(null);
|
|
const deletingProduct = ref(null);
|
|
const isExporting = ref(false);
|
|
const fecha_inicio = ref('');
|
|
const fecha_fin = ref('');
|
|
const currentSearch = ref('');
|
|
|
|
/** Métodos */
|
|
const searcher = useSearcher({
|
|
url: apiURL('inventario'),
|
|
onSuccess: (r) => {
|
|
models.value = r.products || { data: [], total: 0 };
|
|
totalInventoryValue.value = r.total_inventory_value || 0;
|
|
},
|
|
onError: () => {
|
|
models.value = { data: [], total: 0 };
|
|
totalInventoryValue.value = 0;
|
|
}
|
|
});
|
|
|
|
const loadCategories = async () => {
|
|
try {
|
|
const response = await fetch(apiURL('categorias'), {
|
|
headers: {
|
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
const result = await response.json();
|
|
if (result.data && result.data.categories && result.data.categories.data) {
|
|
categories.value = result.data.categories.data;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error al cargar categorías:', error);
|
|
}
|
|
};
|
|
|
|
const loadSubcategories = async () => {
|
|
try {
|
|
const response = await fetch(apiURL('subcategorias'), {
|
|
headers: {
|
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
const result = await response.json();
|
|
if (result.data && result.data.subcategories && result.data.subcategories.data) {
|
|
categories.value = result.data.subcategories.data;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error al cargar subcategorías:', error);
|
|
}
|
|
};
|
|
|
|
const openCreateModal = () => {
|
|
showCreateModal.value = true;
|
|
};
|
|
|
|
const closeCreateModal = () => {
|
|
showCreateModal.value = false;
|
|
};
|
|
|
|
const openEditModal = (product) => {
|
|
editingProduct.value = product;
|
|
showEditModal.value = true;
|
|
};
|
|
|
|
const closeEditModal = () => {
|
|
showEditModal.value = false;
|
|
editingProduct.value = null;
|
|
};
|
|
|
|
const openDeleteModal = (product) => {
|
|
deletingProduct.value = product;
|
|
showDeleteModal.value = true;
|
|
};
|
|
|
|
const closeDeleteModal = () => {
|
|
showDeleteModal.value = false;
|
|
deletingProduct.value = null;
|
|
};
|
|
|
|
const openImportModal = () => {
|
|
showImportModal.value = true;
|
|
};
|
|
|
|
const closeImportModal = () => {
|
|
showImportModal.value = false;
|
|
};
|
|
|
|
const onProductSaved = () => {
|
|
applyFilters();
|
|
};
|
|
|
|
const onProductsImported = () => {
|
|
applyFilters();
|
|
};
|
|
|
|
const openSerials = (product) => {
|
|
router.push({ name: 'pos.inventory.serials', params: { id: product.id } });
|
|
};
|
|
|
|
const confirmDelete = async (id) => {
|
|
try {
|
|
const response = await fetch(apiURL(`inventario/${id}`), {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
Notify.success('Producto eliminado exitosamente');
|
|
closeDeleteModal();
|
|
applyFilters();
|
|
} else {
|
|
Notify.error('Error al eliminar el producto');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
Notify.error('Error al eliminar el producto');
|
|
}
|
|
};
|
|
|
|
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 = () => {
|
|
applyFilters();
|
|
};
|
|
|
|
const exportReport = async () => {
|
|
try {
|
|
isExporting.value = true;
|
|
|
|
const filters = {
|
|
category_id: selectedCategory.value || null,
|
|
};
|
|
|
|
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);
|
|
|
|
Notify.success('Reporte exportado exitosamente');
|
|
} catch (error) {
|
|
console.error('Error al exportar:', error);
|
|
|
|
// 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 {
|
|
isExporting.value = false;
|
|
}
|
|
};
|
|
|
|
/** Ciclos */
|
|
onMounted(() => {
|
|
loadCategories();
|
|
loadSubcategories();
|
|
applyFilters();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<SearcherHead
|
|
:title="$t('inventory.title')"
|
|
placeholder="Buscar por nombre o SKU..."
|
|
@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
|
|
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"
|
|
@click="exportReport"
|
|
:disabled="isExporting"
|
|
title="Exportar inventario a Excel"
|
|
>
|
|
<GoogleIcon :name="isExporting ? 'hourglass_empty' : 'download'" class="text-xl" :class="{ 'animate-spin': isExporting }" />
|
|
{{ isExporting ? 'Exportando...' : 'Exportar' }}
|
|
</button>
|
|
<button
|
|
v-if="can('import')"
|
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
|
@click="openImportModal"
|
|
title="Importar productos desde Excel"
|
|
>
|
|
<GoogleIcon name="upload" class="text-xl" />
|
|
Importar
|
|
</button>
|
|
<button
|
|
v-if="can('create')"
|
|
class="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
|
@click="openCreateModal"
|
|
>
|
|
<GoogleIcon name="add" class="text-xl" />
|
|
Nuevo Producto
|
|
</button>
|
|
</SearcherHead>
|
|
|
|
<!-- Filtros -->
|
|
<div class="pt-4 pb-2">
|
|
<div class="flex items-end gap-4">
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Filtrar por categoría:</label>
|
|
<select
|
|
v-model="selectedCategory"
|
|
@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">
|
|
{{ category.name }}
|
|
</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>
|
|
|
|
<!-- Estadísticas del Inventario -->
|
|
<div class="pt-4 pb-2">
|
|
<div class="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg shadow-lg p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-white/20 backdrop-blur-sm rounded-full p-3">
|
|
<GoogleIcon name="inventory_2" class="text-3xl text-white" />
|
|
</div>
|
|
<div>
|
|
<p class="text-indigo-100 text-sm font-medium">Valor Total del Inventario</p>
|
|
<p class="text-white text-3xl font-bold">
|
|
{{ formatCurrency(totalInventoryValue) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="text-indigo-100 text-sm font-medium">Total de Productos</p>
|
|
<p class="text-white text-2xl font-bold">{{ models.total || 0 }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pt-2 w-full">
|
|
<Table
|
|
:items="models"
|
|
:processing="searcher.processing"
|
|
@send-pagination="(page) => searcher.pagination(page)"
|
|
>
|
|
<template #head>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">SKU / CÓDIGO</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRODUCTO</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CLAVE SAT</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CLASIFICACIÓN</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRECIO</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">STOCK</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TOTAL</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ACCIONES</th>
|
|
</template>
|
|
<template #body="{items}">
|
|
<tr
|
|
v-for="model in items"
|
|
:key="model.id"
|
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
>
|
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
|
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ model.sku }}</span>
|
|
</td>
|
|
<td class="px-6 py-4 text-center">
|
|
<div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ model.name }}</p>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 text-center">
|
|
<div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ model.key_sat }}</p>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-center flex flex-col items-center gap-1">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
|
{{ model.category?.name || '-' }}
|
|
</span>
|
|
<template v-if="model.subcategory">
|
|
<span class="text-gray-400 text-xs dark:text-gray-600"> {{ model.subcategory.name }} </span>
|
|
</template>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
|
<div class="text-sm">
|
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
|
{{ formatCurrency(model.price?.retail_price) }}
|
|
</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
Costo: {{ formatCurrency(model.price?.cost) }}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
|
<span
|
|
class="font-bold text-base"
|
|
:class="{
|
|
'text-red-500': model.stock < 10,
|
|
'text-green-600': model.stock >= 10
|
|
}"
|
|
>
|
|
{{ model.stock }}
|
|
</span>
|
|
<p v-if="model.unit_of_measure" class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
|
{{ model.unit_of_measure.abbreviation }}
|
|
</p>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
|
<div class="text-sm">
|
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
|
{{ formatCurrency(model.inventory_value) }}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
|
<div class="flex items-center justify-center gap-2">
|
|
<button
|
|
v-if="!model.unit_of_measure?.allows_decimals"
|
|
@click="openSerials(model)"
|
|
class="text-emerald-600 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-300 transition-colors"
|
|
title="Gestionar números de serie"
|
|
>
|
|
<GoogleIcon name="qr_code_2" class="text-xl" />
|
|
</button>
|
|
<span
|
|
v-else
|
|
class="text-gray-400 dark:text-gray-600 cursor-not-allowed"
|
|
:title="`No disponible: ${model.unit_of_measure.name} (${model.unit_of_measure.abbreviation}) permite decimales`"
|
|
>
|
|
<GoogleIcon name="qr_code_2" class="text-xl opacity-30" />
|
|
</span>
|
|
<button
|
|
v-if="can('edit')"
|
|
@click="openEditModal(model)"
|
|
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
|
title="Editar producto"
|
|
>
|
|
<GoogleIcon name="edit" class="text-xl" />
|
|
</button>
|
|
<button
|
|
v-if="can('destroy')"
|
|
@click="openDeleteModal(model)"
|
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
|
title="Eliminar producto"
|
|
>
|
|
<GoogleIcon name="delete" class="text-xl" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template #empty>
|
|
<td colspan="8" class="table-cell text-center">
|
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
|
<GoogleIcon
|
|
name="inventory_2"
|
|
class="text-6xl mb-2 opacity-50"
|
|
/>
|
|
<p class="font-semibold">
|
|
{{ $t('registers.empty') }}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</template>
|
|
</Table>
|
|
</div>
|
|
|
|
<!-- Modal de Crear Producto -->
|
|
<CreateModal
|
|
v-if="can('create')"
|
|
:show="showCreateModal"
|
|
@close="closeCreateModal"
|
|
@created="onProductSaved"
|
|
/>
|
|
|
|
<!-- Modal de Editar Producto -->
|
|
<EditModal
|
|
v-if="can('edit')"
|
|
:show="showEditModal"
|
|
:product="editingProduct"
|
|
@close="closeEditModal"
|
|
@updated="onProductSaved"
|
|
/>
|
|
|
|
<!-- Modal de Eliminar Producto -->
|
|
<DeleteModal
|
|
v-if="can('destroy')"
|
|
:show="showDeleteModal"
|
|
:product="deletingProduct"
|
|
@close="closeDeleteModal"
|
|
@confirm="confirmDelete"
|
|
/>
|
|
|
|
<!-- Modal de Importar Productos -->
|
|
<ImportModal
|
|
v-if="can('import')"
|
|
:show="showImportModal"
|
|
@close="closeImportModal"
|
|
@imported="onProductsImported"
|
|
/>
|
|
</div>
|
|
</template>
|