feature-comercial-module-ts #13

Merged
edgar.mendez merged 38 commits from feature-comercial-module-ts into develop 2026-03-04 15:07:09 +00:00
2 changed files with 386 additions and 39 deletions
Showing only changes of commit 2bdccbe6c6 - Show all commits

View File

@ -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;
}
await fetchPurchaseDetails();
// 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>
No. Orden #{{ purchaseOrderNumber }} |
<span class="text-primary font-semibold">Distribución Multi-Almacén</span>
<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"
: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" />
<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)"
/>
</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>

View File

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