- 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.
551 lines
24 KiB
Vue
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 m³</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 m³</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 m³</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>
|