edgar.mendez d1c203cd0e feat(warehouse): add inventory management features
- Implemented getWarehouseById method in warehouseService to fetch warehouse details by ID.
- Added new types for warehouse inventory management in warehouse.d.ts and warehouse.inventory.d.ts.
- Created WarehouseAddInventory.vue component for handling inventory entries with serial number management.
- Developed inventoryWarehouseServices for adding inventory through API.
- Updated router to include the new inventory management component.
- Added Docker configuration files for production deployment.
- Created Nginx configuration for serving the application.
- Added .dockerignore and .env.production for environment-specific settings.
2026-02-13 13:49:41 -06:00

551 lines
24 KiB
Vue

<template>
<div class="space-y-6">
<!-- Toast Notifications -->
<Toast position="bottom-right" />
<!-- Breadcrumb -->
<Breadcrumb :home="breadcrumbHome" :model="breadcrumbItems" />
<!-- Page Header -->
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-col gap-2">
<h1
class="text-surface-900 dark:text-white text-3xl md:text-4xl font-black leading-tight tracking-tight">
{{ warehouseData?.warehouse.name || 'Cargando...' }}
</h1>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2 text-surface-500 dark:text-surface-400">
<i class="pi pi-hash text-sm"></i>
<span class="text-sm">{{ warehouseData?.warehouse.code || '-' }}</span>
</div>
<div class="size-1 bg-surface-300 dark:bg-surface-600 rounded-full"></div>
<div class="flex items-center gap-2 text-surface-500 dark:text-surface-400"
v-if="warehouseData?.warehouse.address">
<i class="pi pi-map-marker text-sm"></i>
<span class="text-sm">{{ warehouseData.warehouse.address }}</span>
</div>
<div class="size-1 bg-surface-300 dark:bg-surface-600 rounded-full"
v-if="warehouseData?.warehouse.address"></div>
<Tag :value="warehouseData?.warehouse.is_active ? 'Operacional' : 'Inactivo'"
:severity="warehouseData?.warehouse.is_active ? 'success' : 'secondary'" />
</div>
</div>
<div class="flex gap-3">
<Button label="Exportar Datos" icon="pi pi-download" outlined severity="secondary" />
<Button icon="pi pi-print" outlined severity="secondary" />
<Button label="Entradas" icon="pi pi-plus" @click="openBatchAdd" />
</div>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Total SKUs -->
<!-- <Card class="shadow-sm">
<template #content>
<div class="space-y-3">
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Total SKUs</p>
<p class="text-2xl font-bold text-surface-900 dark:text-white">
{{ warehouseData?.stocks.length || 0 }}
</p>
<div class="flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400">
<i class="pi pi-box text-xs"></i>
Productos en stock
</div>
</div>
</template>
</Card>
-->
<!-- Low Stock Items -->
<!-- <Card class="shadow-sm border-l-4 border-l-red-500">
<template #content>
<div class="space-y-3">
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Items con Stock Bajo</p>
<p class="text-2xl font-bold text-surface-900 dark:text-white">
{{ warehouseData?.stocks.filter(s => s.stock_min && s.stock < s.stock_min).length || 0 }}
</p>
<div class="flex items-center gap-1 text-xs font-semibold text-red-600 dark:text-red-400">
<i class="pi pi-exclamation-triangle text-xs"></i>
Acción requerida
</div>
</div>
</template>
</Card> -->
<!-- Total Items -->
<!-- <Card class="shadow-sm">
<template #content>
<div class="space-y-3">
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Items Totales</p>
<p class="text-2xl font-bold text-surface-900 dark:text-white">
{{ warehouseData?.items.length || 0 }}
</p>
<div class="flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400">
<i class="pi pi-database text-xs"></i>
Items registrados
</div>
</div>
</template>
</Card> -->
<!-- Total Stock -->
<Card class="shadow-sm">
<template #content>
<div class="space-y-3">
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Stock Total</p>
<p class="text-2xl font-bold text-surface-900 dark:text-white">
{{warehouseData?.stocks.reduce((sum, s) => sum + s.stock, 0).toLocaleString() || 0}}
</p>
<div class="flex items-center gap-1 text-xs font-semibold text-green-600 dark:text-green-400">
<i class="pi pi-chart-bar text-xs"></i>
Unidades en almacén
</div>
</div>
</template>
</Card>
</div>
<!-- Tabs & Main Content Table -->
<Card class="shadow-sm">
<template #content>
<TabView v-model:activeIndex="activeTab" class="w-full">
<!-- Stock Actual Tab -->
<TabPanel value="0">
<template #header>
<div class="flex items-center gap-2">
<i class="pi pi-box"></i>
<span>Stock Actual</span>
</div>
</template>
<!-- Filters -->
<div class="flex flex-wrap items-center justify-end gap-3 mb-4">
<Select v-model="selectedCategory" :options="categoryOptions" optionLabel="label"
optionValue="value" placeholder="Todas las Categorías" class="w-full md:w-48" />
<Select v-model="selectedStockLevel" :options="stockLevelOptions" optionLabel="label"
optionValue="value" placeholder="Todos los Niveles" class="w-full md:w-48" />
<Button icon="pi pi-filter" outlined severity="secondary" />
</div>
<!-- Current Stock Table -->
<DataTable :value="inventoryData" :paginator="true" :rows="10"
:rowsPerPageOptions="[10, 25, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} resultados"
:loading="loading" stripedRows responsiveLayout="scroll" class="text-sm">
<Column field="sku" header="SKU" sortable class="font-mono">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.sku }}</span>
</template>
</Column>
<Column field="product" header="Producto" sortable>
<template #body="slotProps">
<div class="flex flex-col gap-1">
<span class="font-semibold">{{ slotProps.data.product }}</span>
<span class="text-xs text-surface-500">{{ slotProps.data.category }}</span>
</div>
</template>
</Column>
<Column field="quantity" header="Cantidad" sortable>
<template #body="slotProps">
<span class="font-bold tabular-nums">{{ slotProps.data.quantity.toLocaleString()
}}</span>
</template>
</Column>
<Column field="unit" header="Unidad" sortable />
<Column field="location" header="Ubicación" sortable>
<template #body="slotProps">
<div class="flex items-center gap-2">
<i class="pi pi-map-marker text-surface-400 text-xs"></i>
<span>{{ slotProps.data.location }}</span>
</div>
</template>
</Column>
<Column field="status" header="Estado" sortable>
<template #body="slotProps">
<Tag :value="slotProps.data.status"
:severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column field="lastUpdate" header="Última Actualización" sortable>
<template #body="slotProps">
<span class="text-xs text-surface-500">{{ slotProps.data.lastUpdate }}</span>
</template>
</Column>
<Column header="Acciones" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-eye" outlined rounded size="small" severity="secondary"
@click="viewItem(slotProps.data)" />
<Button icon="pi pi-pencil" outlined rounded size="small"
@click="editItem(slotProps.data)" />
</div>
</template>
</Column>
</DataTable>
</TabPanel>
<!-- Historial de Movimientos Tab -->
<TabPanel value="1">
<template #header>
<div class="flex items-center gap-2">
<i class="pi pi-history"></i>
<span>Historial de Movimientos</span>
</div>
</template>
<!-- Movement History Table -->
<DataTable :value="movementHistory" :paginator="true" :rows="10"
:rowsPerPageOptions="[10, 25, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} resultados"
:loading="loading" stripedRows responsiveLayout="scroll" class="text-sm">
<Column field="date" header="Fecha" sortable />
<Column field="type" header="Tipo" sortable>
<template #body="slotProps">
<Tag :value="slotProps.data.type"
:severity="getMovementTypeSeverity(slotProps.data.type)" />
</template>
</Column>
<Column field="product" header="Producto" sortable />
<Column field="quantity" header="Cantidad" sortable>
<template #body="slotProps">
<span class="font-bold tabular-nums">{{ slotProps.data.quantity }}</span>
</template>
</Column>
<Column field="user" header="Usuario" sortable />
<Column field="reference" header="Referencia" sortable />
</DataTable>
</TabPanel>
</TabView>
</template>
</Card>
<!-- Secondary Section: Recent Movements & Insights -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Quick Activity Log -->
<!-- <Card class="lg:col-span-2 shadow-sm">
<template #header>
<div class="flex items-center justify-between p-4">
<h3 class="font-bold text-surface-900 dark:text-white">Registro Rápido de Actividad</h3>
<Button label="Ver Todos los Movimientos" link class="text-sm" @click="viewAllMovements" />
</div>
</template>
<template #content>
<div class="space-y-4">
<div v-for="activity in recentActivities" :key="activity.id"
class="flex items-center gap-4 py-3 border-b border-surface-200 dark:border-surface-700 last:border-b-0">
<div :class="[
'flex items-center justify-center size-10 rounded-full',
activity.type === 'in' ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'
]">
<i :class="[
'pi text-sm',
activity.type === 'in' ? 'pi-arrow-down text-green-600' : 'pi-arrow-up text-red-600'
]"></i>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-surface-900 dark:text-white">{{ activity.action }}</p>
<p class="text-sm text-surface-500 dark:text-surface-400">{{ activity.product }}</p>
</div>
<div class="text-right">
<p class="font-bold text-surface-900 dark:text-white">{{ activity.quantity }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">{{ activity.time }}</p>
</div>
</div>
</div>
</template>
</Card> -->
<!-- Warehouse Health Insights -->
<!-- <Card
class="shadow-sm bg-primary-50 dark:bg-primary-900/10 border border-primary-200 dark:border-primary-800">
<template #content>
<div class="space-y-4">
<div>
<h3 class="font-bold text-surface-900 dark:text-white mb-2">Salud del Almacén</h3>
<p class="text-sm text-surface-600 dark:text-surface-300 leading-relaxed">
North Logistics Center está actualmente al 84% de capacidad. Recomendamos auditar la
Zona B
para optimización de espacio potencial.
</p>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-surface-700 dark:text-surface-200">Capacidad Total</span>
<span class="font-bold text-surface-900 dark:text-white">50,000 </span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-surface-700 dark:text-surface-200">Espacio Utilizado</span>
<span class="font-bold text-surface-900 dark:text-white">42,000 </span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-surface-700 dark:text-surface-200">Espacio Disponible</span>
<span class="font-bold text-primary-700 dark:text-primary-400">8,000 </span>
</div>
</div>
<Button label="Generar Reporte Completo" class="w-full" outlined @click="generateReport" />
</div>
</template>
</Card> -->
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { warehouseService } from '../services/warehouseService';
import type { WarehouseDetailData } from '../types/warehouse';
// PrimeVue Components
import Toast from 'primevue/toast';
import Breadcrumb from 'primevue/breadcrumb';
import Card from 'primevue/card';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Select from 'primevue/select';
const router = useRouter();
const route = useRoute();
const toast = useToast();
// Reactive State
const warehouseData = ref<WarehouseDetailData | null>(null);
const loading = ref(false);
const activeTab = ref(0);
const selectedCategory = ref('all');
const selectedStockLevel = ref('all');
// Breadcrumb
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
const breadcrumbItems = computed(() => [
{ label: 'Almacenes', to: '/warehouse' },
{ label: warehouseData.value?.warehouse.name || 'Cargando...' }
]);
// Filter Options
const categoryOptions = [
{ label: 'Todas las Categorías', value: 'all' },
{ label: 'Electrónica', value: 'electronics' },
{ label: 'Maquinaria', value: 'machinery' },
{ label: 'Textiles', value: 'textiles' },
];
const stockLevelOptions = [
{ label: 'Todos los Niveles', value: 'all' },
{ label: 'Stock Bajo', value: 'low' },
{ label: 'En Stock', value: 'in_stock' },
{ label: 'Sobrestock', value: 'overstock' },
];
// Inventory Data from API
const inventoryData = ref<any[]>([]);
// Mock Data - Movement History
const movementHistory = ref([
{
date: '2024-01-30 14:30',
type: 'Entrada',
product: 'Laptop Dell XPS 15',
quantity: '+50',
user: 'Juan Pérez',
reference: 'PO-2024-156'
},
{
date: '2024-01-30 12:15',
type: 'Salida',
product: 'Monitor LG 27" 4K',
quantity: '-25',
user: 'María García',
reference: 'SO-2024-892'
},
{
date: '2024-01-30 10:00',
type: 'Entrada',
product: 'Silla Ergonómica Pro',
quantity: '+100',
user: 'Carlos López',
reference: 'PO-2024-155'
},
]);
// Mock Data - Recent Activities (unused - template is commented out)
// const recentActivities = ref([
// {
// id: 1,
// type: 'in',
// action: 'Entrada de Stock',
// product: 'Laptop Dell XPS 15 - 50 unidades',
// quantity: '+50',
// time: 'Hace 2 horas'
// },
// {
// id: 2,
// type: 'out',
// action: 'Salida de Stock',
// product: 'Monitor LG 27" - 25 unidades',
// quantity: '-25',
// time: 'Hace 4 horas'
// },
// {
// id: 3,
// type: 'in',
// action: 'Entrada de Stock',
// product: 'Silla Ergonómica - 100 unidades',
// quantity: '+100',
// time: 'Hace 6 horas'
// },
// ]);
// Methods
const getStatusSeverity = (status: string) => {
const severityMap: Record<string, string> = {
'En Stock': 'success',
'Stock Bajo': 'warn',
'Stock Crítico': 'danger',
'Sobrestock': 'info',
};
return severityMap[status] || 'secondary';
};
const getMovementTypeSeverity = (type: string) => {
const severityMap: Record<string, string> = {
'Entrada': 'success',
'Salida': 'danger',
'Transferencia': 'info',
'Ajuste': 'warn',
};
return severityMap[type] || 'secondary';
};
const openBatchAdd = () => {
router.push({ name: 'BatchAddInventory' });
};
const viewItem = (item: any) => {
toast.add({
severity: 'info',
summary: 'Ver Item',
detail: `Visualizando: ${item.product}`,
life: 3000
});
};
const editItem = (item: any) => {
toast.add({
severity: 'info',
summary: 'Editar Item',
detail: `Editando: ${item.product}`,
life: 3000
});
};
// Unused methods (template sections are commented out)
// const viewAllMovements = () => {
// activeTab.value = 1;
// };
// const generateReport = () => {
// toast.add({
// severity: 'success',
// summary: 'Generando Reporte',
// detail: 'El reporte se está generando...',
// life: 3000
// });
// };
// Helper function to get stock status
const getStockStatus = (stock: number, stockMin: number | null) => {
if (!stockMin) return 'En Stock';
if (stock === 0) return 'Sin Stock';
if (stock < stockMin) return 'Stock Bajo';
if (stock < stockMin * 1.5) return 'Stock Crítico';
return 'En Stock';
};
// Helper function to format date
const formatRelativeTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `Hace ${diffDays} día${diffDays > 1 ? 's' : ''}`;
if (diffHours > 0) return `Hace ${diffHours} hora${diffHours > 1 ? 's' : ''}`;
return 'Hace menos de 1 hora';
};
// Lifecycle
onMounted(async () => {
const warehouseId = route.params.id;
if (!warehouseId) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'ID de almacén no válido',
life: 3000
});
router.push({ name: 'Warehouses' });
return;
}
loading.value = true;
try {
const response = await warehouseService.getWarehouseById(Number(warehouseId));
warehouseData.value = response.data;
// Mapear stocks a formato de la tabla
inventoryData.value = response.data.stocks.map(stock => ({
sku: stock.product.sku,
product: stock.product.name,
category: stock.product.description || 'Sin categoría',
quantity: stock.stock,
unit: 'Unidades',
location: `Almacén ${response.data.warehouse.code}`,
status: getStockStatus(stock.stock, stock.stock_min),
lastUpdate: formatRelativeTime(stock.updated_at),
productId: stock.product_id,
stockMin: stock.stock_min,
stockMax: stock.stock_max,
}));
toast.add({
severity: 'success',
summary: 'Datos Cargados',
detail: `Almacén ${response.data.warehouse.name} cargado exitosamente`,
life: 3000
});
} catch (error) {
console.error('Error al cargar los datos del almacén:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Error al cargar los datos del almacén',
life: 3000
});
router.push({ name: 'Warehouses' });
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.tabular-nums {
font-variant-numeric: tabular-nums;
}
</style>