feat: enhance warehouse inventory management by adding product selection modal and manual entry mode
This commit is contained in:
parent
0071b7f4dc
commit
2bdccbe6c6
@ -8,12 +8,19 @@ import Dropdown from 'primevue/dropdown';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Badge from 'primevue/badge';
|
||||
import Toast from 'primevue/toast';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { purchaseServices } from '../../purchases/services/purchaseServices';
|
||||
import type { PurchaseDetailResponse } from '../../purchases/types/purchases';
|
||||
import { useWarehouseStore } from '../../../stores/warehouseStore';
|
||||
import { useProductStore } from '../../products/stores/productStore';
|
||||
import { inventoryWarehouseServices } from '../services/inventoryWarehouse.services';
|
||||
import type { InventoryProductItem, CreateInventoryRequest } from '../types/warehouse.inventory';
|
||||
import type { Product as ProductType } from '../../products/types/product';
|
||||
|
||||
interface SerialNumber {
|
||||
serial: string;
|
||||
@ -38,11 +45,22 @@ const toast = useToast();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const warehouseStore = useWarehouseStore();
|
||||
const productStore = useProductStore();
|
||||
|
||||
// Data from API
|
||||
const purchaseData = ref<PurchaseDetailResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
// Modo de operación: 'purchase' o 'manual'
|
||||
const operationMode = computed(() => {
|
||||
return route.query.purchaseId ? 'purchase' : 'manual';
|
||||
});
|
||||
|
||||
// Almacén de destino (para modo manual)
|
||||
const targetWarehouseId = computed(() => {
|
||||
return route.query.warehouse ? Number(route.query.warehouse) : null;
|
||||
});
|
||||
|
||||
// Data
|
||||
const purchaseOrderNumber = ref('ORD-2023-001');
|
||||
const totalItemsPO = ref(12);
|
||||
@ -92,6 +110,12 @@ const expandedRows = ref<any[]>([]);
|
||||
const newSerialNumber = ref('');
|
||||
const newSerialWarehouse = ref<number>(1);
|
||||
|
||||
// Modal de productos
|
||||
const showProductModal = ref(false);
|
||||
const productSearch = ref('');
|
||||
const selectedProducts = ref<ProductType[]>([]);
|
||||
const loadingProducts = ref(false);
|
||||
|
||||
const totalReceived = computed(() => {
|
||||
return products.value.reduce((sum, p) => sum + p.quantityReceived, 0);
|
||||
});
|
||||
@ -123,11 +147,16 @@ const warehouseSummary = computed(() => {
|
||||
});
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
// En modo manual sin productos, no es válido
|
||||
if (operationMode.value === 'manual' && products.value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return products.value.every(product => {
|
||||
if (product.requiresSerial) {
|
||||
return product.serialNumbers.length === product.quantityOrdered;
|
||||
} else {
|
||||
return product.warehouseId !== null;
|
||||
return product.warehouseId !== null && product.quantityReceived > 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -183,12 +212,6 @@ async function confirmReceipt() {
|
||||
return;
|
||||
}
|
||||
|
||||
const purchaseId = route.query.purchaseId;
|
||||
if (!purchaseId) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'ID de compra no válido', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
@ -229,8 +252,10 @@ async function confirmReceipt() {
|
||||
// Enviar al API
|
||||
const response = await inventoryWarehouseServices.addInventory(requestData);
|
||||
|
||||
// Actualizar estado de la compra a "Ingresada a Inventario" (4)
|
||||
await purchaseServices.updatePurchaseStatus(Number(purchaseId), '4');
|
||||
// Si es desde una compra, actualizar estado
|
||||
if (operationMode.value === 'purchase' && route.query.purchaseId) {
|
||||
await purchaseServices.updatePurchaseStatus(Number(route.query.purchaseId), '4');
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
@ -239,7 +264,7 @@ async function confirmReceipt() {
|
||||
life: 4000
|
||||
});
|
||||
|
||||
// Regresar a la vista de compras
|
||||
// Regresar a la vista anterior
|
||||
setTimeout(() => {
|
||||
router.back();
|
||||
}, 1000);
|
||||
@ -261,13 +286,7 @@ async function fetchPurchaseDetails() {
|
||||
const purchaseId = route.query.purchaseId;
|
||||
|
||||
if (!purchaseId) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'No se proporcionó un ID de compra válido',
|
||||
life: 3000
|
||||
});
|
||||
router.back();
|
||||
// Si no hay purchaseId, estamos en modo manual
|
||||
return;
|
||||
}
|
||||
|
||||
@ -309,18 +328,136 @@ async function fetchPurchaseDetails() {
|
||||
}
|
||||
}
|
||||
|
||||
function initializeManualMode() {
|
||||
// En modo manual, limpiar productos de ejemplo y establecer almacén por defecto
|
||||
products.value = [];
|
||||
purchaseOrderNumber.value = 'Entrada Manual';
|
||||
totalItemsPO.value = 0;
|
||||
|
||||
// Obtener el nombre del almacén si se especificó
|
||||
if (targetWarehouseId.value) {
|
||||
const warehouse = warehouseStore.warehouses.find(w => w.id === targetWarehouseId.value);
|
||||
if (warehouse) {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Entrada Manual',
|
||||
detail: `Agregando inventario al almacén: ${warehouse.name}`,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await warehouseStore.fetchWarehouses();
|
||||
// Establecer el primer almacén activo como predeterminado
|
||||
if (warehouseStore.activeWarehouses.length > 0) {
|
||||
newSerialWarehouse.value = warehouseStore.activeWarehouses[0]?.id || 1;
|
||||
}
|
||||
|
||||
// Cargar datos según el modo
|
||||
if (operationMode.value === 'purchase') {
|
||||
await fetchPurchaseDetails();
|
||||
} else {
|
||||
initializeManualMode();
|
||||
}
|
||||
});
|
||||
|
||||
function cancel() {
|
||||
// Lógica para cancelar y regresar
|
||||
router.back();
|
||||
}
|
||||
|
||||
// Funciones para el modal de productos
|
||||
const openProductModal = async () => {
|
||||
showProductModal.value = true;
|
||||
loadingProducts.value = true;
|
||||
try {
|
||||
await productStore.fetchProducts();
|
||||
} catch (error) {
|
||||
console.error('Error al cargar productos:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'No se pudieron cargar los productos',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
loadingProducts.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeProductModal = () => {
|
||||
showProductModal.value = false;
|
||||
selectedProducts.value = [];
|
||||
productSearch.value = '';
|
||||
};
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
if (!productSearch.value) {
|
||||
return productStore.activeProducts;
|
||||
}
|
||||
|
||||
const search = productSearch.value.toLowerCase();
|
||||
return productStore.activeProducts.filter(p =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.sku.toLowerCase().includes(search) ||
|
||||
(p.code && p.code.toLowerCase().includes(search))
|
||||
);
|
||||
});
|
||||
|
||||
const addSelectedProducts = () => {
|
||||
if (selectedProducts.value.length === 0) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Selección Requerida',
|
||||
detail: 'Por favor seleccione al menos un producto',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
selectedProducts.value.forEach(product => {
|
||||
// Verificar si el producto ya está en la lista
|
||||
const exists = products.value.find(p => p.id === product.id);
|
||||
if (!exists) {
|
||||
products.value.push({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
sku: product.sku,
|
||||
category: product.description || 'Sin categoría',
|
||||
quantityOrdered: 1, // Cantidad inicial
|
||||
quantityReceived: 0,
|
||||
warehouseId: targetWarehouseId.value || null,
|
||||
requiresSerial: product.is_serial,
|
||||
serialNumbers: [],
|
||||
purchaseCost: 0, // El usuario puede ajustar esto
|
||||
attributes: product.attributes || undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Productos Agregados',
|
||||
detail: `Se agregaron ${selectedProducts.value.length} producto(s)`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
closeProductModal();
|
||||
};
|
||||
|
||||
const removeProduct = (productId: number) => {
|
||||
const index = products.value.findIndex(p => p.id === productId);
|
||||
if (index > -1) {
|
||||
products.value.splice(index, 1);
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Producto Eliminado',
|
||||
detail: 'El producto ha sido eliminado de la lista',
|
||||
life: 2000
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -343,13 +480,21 @@ function cancel() {
|
||||
<div>
|
||||
<h2 class="text-3xl font-black text-slate-900 tracking-tight">Registrar Entrada de Mercancía</h2>
|
||||
<p class="text-slate-500 mt-1 flex items-center gap-2">
|
||||
<i class="pi pi-receipt text-base"></i>
|
||||
<i :class="operationMode === 'purchase' ? 'pi pi-receipt' : 'pi pi-warehouse'" class="text-base"></i>
|
||||
<template v-if="operationMode === 'purchase'">
|
||||
No. Orden #{{ purchaseOrderNumber }} |
|
||||
<span class="text-primary font-semibold">Distribución Multi-Almacén</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-primary font-semibold">Entrada Manual de Inventario</span>
|
||||
<template v-if="targetWarehouseId">
|
||||
| Almacén: {{ warehouseStore.warehouses.find(w => w.id === targetWarehouseId)?.name }}
|
||||
</template>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Card class="shadow-sm">
|
||||
<Card class="shadow-sm" v-if="operationMode === 'purchase'">
|
||||
<template #content>
|
||||
<div class="flex gap-4 px-2">
|
||||
<div class="text-center">
|
||||
@ -364,7 +509,21 @@ function cancel() {
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Button icon="pi pi-eye" label="Ver Detalles" severity="secondary" outlined />
|
||||
<Button
|
||||
v-if="operationMode === 'manual'"
|
||||
icon="pi pi-plus"
|
||||
label="Agregar Producto"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openProductModal"
|
||||
/>
|
||||
<Button
|
||||
v-if="operationMode === 'purchase'"
|
||||
icon="pi pi-eye"
|
||||
label="Ver Detalles"
|
||||
severity="secondary"
|
||||
outlined
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -372,7 +531,9 @@ function cancel() {
|
||||
<Card>
|
||||
<template #header>
|
||||
<div class="px-6 py-4 border-b border-slate-200 bg-slate-50/50 flex justify-between items-center">
|
||||
<h3 class="font-bold text-slate-900">Productos de la Orden</h3>
|
||||
<h3 class="font-bold text-slate-900">
|
||||
{{ operationMode === 'purchase' ? 'Productos de la Orden' : 'Productos a Ingresar' }}
|
||||
</h3>
|
||||
<span class="text-xs text-slate-500 flex items-center gap-1">
|
||||
<i class="pi pi-info-circle text-sm"></i>
|
||||
Seleccione el almacén de destino por cada producto
|
||||
@ -380,7 +541,20 @@ function cancel() {
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="overflow-x-auto -m-6">
|
||||
<!-- Empty State para modo manual -->
|
||||
<div v-if="operationMode === 'manual' && products.length === 0" class="text-center py-12">
|
||||
<i class="pi pi-inbox text-6xl text-slate-300 mb-4"></i>
|
||||
<h3 class="text-lg font-semibold text-slate-700 mb-2">No hay productos agregados</h3>
|
||||
<p class="text-sm text-slate-500 mb-6">Comienza agregando productos para crear la entrada de inventario</p>
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
label="Agregar Producto"
|
||||
severity="primary"
|
||||
@click="openProductModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto -m-6">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="text-left text-xs font-bold text-slate-500 uppercase tracking-wider bg-slate-50">
|
||||
@ -414,7 +588,22 @@ function cancel() {
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-600">{{ product.sku }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm font-bold text-slate-900">{{ product.quantityOrdered }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm font-bold text-slate-900">
|
||||
<template v-if="operationMode === 'manual'">
|
||||
<InputNumber
|
||||
v-model="product.quantityOrdered"
|
||||
:min="1"
|
||||
showButtons
|
||||
buttonLayout="horizontal"
|
||||
:step="1"
|
||||
class="w-full max-w-[120px] mx-auto"
|
||||
:inputStyle="{ textAlign: 'center', fontWeight: 'bold', fontSize: '0.875rem', width: '60px' }"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ product.quantityOrdered }}
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<InputNumber v-if="!product.requiresSerial" v-model="product.quantityReceived"
|
||||
:max="product.quantityOrdered" :min="0"
|
||||
@ -440,14 +629,32 @@ function cancel() {
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<Button v-if="product.requiresSerial"
|
||||
<template v-if="product.requiresSerial">
|
||||
<Button
|
||||
:label="isRowExpanded(product) ? 'OCULTAR' : 'GESTIONAR SERIES'"
|
||||
:icon="isRowExpanded(product) ? 'pi pi-chevron-up' : 'pi pi-qrcode'"
|
||||
:severity="isRowExpanded(product) ? 'secondary' : 'info'"
|
||||
:outlined="!isRowExpanded(product)"
|
||||
size="small"
|
||||
@click="toggleRow(product)" />
|
||||
<Badge v-else value="Estándar" severity="secondary" />
|
||||
@click="toggleRow(product)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Badge value="Estándar" severity="secondary" />
|
||||
</template>
|
||||
|
||||
<!-- Botón eliminar en modo manual -->
|
||||
<Button
|
||||
v-if="operationMode === 'manual'"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="ml-2"
|
||||
@click="removeProduct(product.id)"
|
||||
v-tooltip.top="'Eliminar producto'"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -606,12 +813,148 @@ function cancel() {
|
||||
<!-- Footer Actions -->
|
||||
<div class="flex items-center justify-end gap-4 pt-4 border-t border-slate-200">
|
||||
<Button label="Cancelar" severity="secondary" text @click="cancel" />
|
||||
<Button label="Confirmar Recepción Multi-Almacén"
|
||||
<Button
|
||||
:label="operationMode === 'purchase' ? 'Confirmar Recepción Multi-Almacén' : 'Confirmar Entrada de Inventario'"
|
||||
icon="pi pi-check"
|
||||
:disabled="!isFormValid"
|
||||
@click="confirmReceipt" />
|
||||
:disabled="!isFormValid || products.length === 0"
|
||||
@click="confirmReceipt"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Modal de Selección de Productos -->
|
||||
<Dialog
|
||||
v-model:visible="showProductModal"
|
||||
modal
|
||||
header="Seleccionar Productos"
|
||||
:style="{ width: '90vw', maxWidth: '1200px' }"
|
||||
:contentStyle="{ padding: '0' }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-shopping-cart text-xl"></i>
|
||||
<span class="font-bold text-lg">Seleccionar Productos del Catálogo</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-4">
|
||||
<IconField iconPosition="left">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="productSearch"
|
||||
placeholder="Buscar por nombre, SKU o código..."
|
||||
class="w-full"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<!-- Products Table -->
|
||||
<DataTable
|
||||
v-model:selection="selectedProducts"
|
||||
:value="filteredProducts"
|
||||
:loading="loadingProducts"
|
||||
selectionMode="multiple"
|
||||
dataKey="id"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[5, 10, 20, 50]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} productos"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
||||
>
|
||||
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
||||
|
||||
<Column field="name" header="Producto" sortable style="min-width: 250px">
|
||||
<template #body="slotProps">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-surface-900 dark:text-white">
|
||||
{{ slotProps.data.name }}
|
||||
</span>
|
||||
<span class="text-xs text-surface-500 dark:text-surface-400">
|
||||
{{ slotProps.data.description }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="sku" header="SKU" sortable style="min-width: 120px">
|
||||
<template #body="slotProps">
|
||||
<span class="font-mono text-sm text-surface-600 dark:text-surface-400">
|
||||
{{ slotProps.data.sku }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="code" header="Código" sortable style="min-width: 100px">
|
||||
<template #body="slotProps">
|
||||
<span class="font-mono text-sm text-primary-600 dark:text-primary-400">
|
||||
{{ slotProps.data.code }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="is_serial" header="Tipo" style="min-width: 100px">
|
||||
<template #body="slotProps">
|
||||
<Badge
|
||||
:value="slotProps.data.is_serial ? 'Serial' : 'Estándar'"
|
||||
:severity="slotProps.data.is_serial ? 'info' : 'secondary'"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="suggested_sale_price" header="Precio Sugerido" sortable style="min-width: 130px">
|
||||
<template #body="slotProps">
|
||||
<span class="font-semibold text-green-600 dark:text-green-400">
|
||||
${{ slotProps.data.suggested_sale_price.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center py-8">
|
||||
<i class="pi pi-inbox text-4xl text-surface-300 dark:text-surface-600 mb-4"></i>
|
||||
<p class="text-surface-500 dark:text-surface-400">
|
||||
No se encontraron productos
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<div class="text-center py-8">
|
||||
<i class="pi pi-spin pi-spinner text-4xl text-primary mb-4"></i>
|
||||
<p class="text-surface-500 dark:text-surface-400">
|
||||
Cargando productos...
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-surface-600 dark:text-surface-400">
|
||||
{{ selectedProducts.length }} producto(s) seleccionado(s)
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="closeProductModal"
|
||||
/>
|
||||
<Button
|
||||
label="Agregar Productos"
|
||||
icon="pi pi-plus"
|
||||
:disabled="selectedProducts.length === 0"
|
||||
@click="addSelectedProducts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -426,7 +426,11 @@ const getMovementTypeSeverity = (type: string) => {
|
||||
};
|
||||
|
||||
const openBatchAdd = () => {
|
||||
router.push({ name: 'BatchAddInventory' });
|
||||
const warehouseId = route.params.id;
|
||||
router.push({
|
||||
name: 'WarehouseAddInventory',
|
||||
query: { warehouse: warehouseId }
|
||||
});
|
||||
};
|
||||
|
||||
const viewItem = (item: any) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user