This commit is contained in:
Edgar Méndez Mendoza 2025-09-23 16:18:45 -06:00
parent 8079470f22
commit c95d40787d
6 changed files with 452 additions and 3 deletions

View File

@ -1,12 +1,13 @@
VITE_API_URL=http://backend.holos.test:8080
VITE_BASE_URL=http://frontend.holos.test
VITE_API_URL=http://localhost:8080
VITE_BASE_URL=http://localhost:3000
VITE_REVERB_APP_ID=
VITE_REVERB_APP_KEY=
VITE_REVERB_APP_SECRET=
VITE_REVERB_HOST="backend.holos.test"
VITE_REVERB_HOST="localhost"
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_REVERB_ACTIVE=false
APP_PORT=3000

View File

@ -10,6 +10,7 @@ services:
- frontend-v1:/var/www/gols-frontend-v1/node_modules
networks:
- gols-network
mem_limit: 512m
volumes:
frontend-v1:
driver: local

View File

@ -81,6 +81,14 @@ onMounted(() => {
to="admin.vacations.index"
/>
</Section>
<Section name="Almacén">
<Link
icon="grid_view"
name="Almacén"
to="admin.warehouses.index"
/>
</Section>
<Section name="Capacitaciones">
<DropDown
icon="grid_view"

View File

@ -0,0 +1,386 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import GoogleIcon from '@Shared/GoogleIcon.vue'
import Searcher from '@Holos/Searcher.vue'
import Adding from '@Holos/Button/ButtonRh.vue'
import { api } from '@Services/Api'
import { apiTo } from './Module'
// Reactive state
const warehouses = ref([])
const loading = ref(false)
const error = ref('')
const inventoryMovements = ref([
// kept mock movements for demo; in real app these would come from API
{
id: 1,
date: '2024-01-15T10:30:00',
type: 'entrada',
product: 'Laptop Dell Inspiron 15',
code: 'PROD-001',
quantity: 10,
warehouse: 'Almacén Principal',
reference: 'PO-2024-001',
user: 'Ana García',
status: 'completed'
},
{
id: 2,
date: '2024-01-15T09:15:00',
type: 'salida',
product: 'Mouse Inalámbrico Logitech',
code: 'PROD-002',
quantity: 25,
warehouse: 'Almacén Norte',
reference: 'SO-2024-045',
user: 'Carlos López',
status: 'completed'
}
])
const stockByWarehouse = ref([])
// UI state
const activeTab = ref('warehouses')
const searchTerm = ref('')
const fetchWarehousesFromApi = (q = '') => {
loading.value = true
error.value = ''
api.get(apiTo('index'), {
params: { q },
onStart: () => {
loading.value = true
},
onSuccess: (data, fullPayload) => {
const payload = Array.isArray(data) ? data : (fullPayload && Array.isArray(fullPayload.data) ? fullPayload.data : [])
warehouses.value = payload.map((w) => ({
id: w.id,
code: w.code ?? `WH-${String(w.id).padStart(3, '0')}`,
name: w.name,
location: w.address ?? '',
type: w.type ?? '',
totalProducts: (w.classifications && Array.isArray(w.classifications)) ? w.classifications.length : 0,
totalValue: w.total_value ?? 0,
capacity: w.capacity ?? '0%',
status: w.is_active ? 'active' : 'inactive',
classifications: w.classifications ?? []
}))
},
onFail: (data) => {
error.value = data?.message || 'Error al obtener almacenes'
},
onError: (err) => {
console.error('API Error:', err)
try {
error.value = err?.message || err?.data?.message || 'Error desconocido'
} catch (e) {
error.value = 'Error desconocido'
}
},
onFinish: () => {
loading.value = false
}
})
}
onMounted(() => {
fetchWarehousesFromApi()
})
// Computed totals
const totalInventoryValue = computed(() => warehouses.value.reduce((sum, wh) => sum + (wh.totalValue || 0), 0))
const totalProducts = computed(() => warehouses.value.reduce((sum, wh) => sum + (wh.totalProducts || 0), 0))
// Filtered movements by searchTerm
const filteredMovements = computed(() => {
const q = searchTerm.value.trim().toLowerCase()
if (!q) return inventoryMovements.value
return inventoryMovements.value.filter(m => {
return [m.product, m.code, m.warehouse, m.user, m.reference]
.filter(Boolean)
.some(f => f.toLowerCase().includes(q))
})
})
// Helpers
const parseCapacity = (cap) => {
if (typeof cap === 'string') return parseInt(cap.replace('%', ''), 10) || 0
if (typeof cap === 'number') return Math.round(cap)
return 0
}
const getMovementIconClass = (type) => {
switch (type) {
case 'entrada':
return 'icon-arrow-down-left text-success'
case 'salida':
return 'icon-arrow-up-right text-destructive'
case 'transferencia':
return 'icon-refresh text-warning'
case 'ajuste':
return 'icon-trending-up text-primary'
default:
return 'icon-refresh'
}
}
const movementVariant = (type) => {
const map = { entrada: 'default', salida: 'destructive', transferencia: 'secondary', ajuste: 'outline' }
return map[type] || 'default'
}
const formatCurrency = (value) => {
try {
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(value)
} catch (e) {
return `$${value}`
}
}
</script>
<template>
<div class="p-6 max-w-auto mx-auto">
<!-- Header -->
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-extrabold text-gray-900 dark:text-primary-dt">Inventario</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-primary-dt/70">Control de stock y movimientos por almacén
</p>
</div>
<div>
<Adding text="Nuevo Almacén" />
</div>
</div>
<!-- Searcher for warehouses -->
<div class="mt-6">
<Searcher title="Buscar Almacenes" placeholder="Buscar por nombre o ubicación..."
@search="fetchWarehousesFromApi" />
<!-- Loading / Error -->
<div class="mt-2">
<div v-if="loading" class="text-sm text-muted-foreground">Cargando almacenes...</div>
<div v-if="error" class="mt-2 text-sm text-destructive">{{ error }}</div>
</div>
</div>
<!-- Quick actions -->
<div class="flex items-center space-x-2 mt-4">
<button class="btn btn-outline" aria-label="Entrada">
<span class="icon">+</span> Entrada
</button>
<button class="btn btn-outline" aria-label="Salida">
<span class="icon">-</span> Salida
</button>
<button class="bg-primary text-white" aria-label="Transferencia">Transferencia</button>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
<div class="card">
<div class="card-body p-4 flex items-center space-x-2">
<span class="icon text-primary">W</span>
<div>
<p class="text-2xl font-bold">{{ warehouses.length }}</p>
<p class="text-sm text-muted-foreground">Almacenes</p>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-4 flex items-center space-x-2">
<div class="w-8 h-8 rounded-full bg-success flex items-center justify-center">
<span class="text-success-foreground font-bold text-sm">#</span>
</div>
<div>
<p class="text-2xl font-bold text-success">{{ totalProducts }}</p>
<p class="text-sm text-muted-foreground">Total Productos</p>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-4 flex items-center space-x-2">
<div class="w-8 h-8 rounded-full bg-warning flex items-center justify-center">
<span class="text-warning-foreground font-bold text-sm">$</span>
</div>
<div>
<p class="text-2xl font-bold text-warning">{{ (totalInventoryValue / 1000000).toFixed(1) }}M</p>
<p class="text-sm text-muted-foreground">Valor Total</p>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-4 flex items-center space-x-2">
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center">
<span class="text-primary-foreground">R</span>
</div>
<div>
<p class="text-2xl font-bold text-primary">{{ inventoryMovements.length }}</p>
<p class="text-sm text-muted-foreground">Movimientos Hoy</p>
</div>
</div>
</div>
</div>
<!-- Tabs -->
<div class="mt-6">
<div class="tabs">
<button :class="['tab', { 'active': activeTab === 'warehouses' }]"
@click="activeTab = 'warehouses'">Almacenes</button>
<button :class="['tab', { 'active': activeTab === 'movements' }]"
@click="activeTab = 'movements'">Movimientos</button>
<button :class="['tab', { 'active': activeTab === 'stock' }]" @click="activeTab = 'stock'">Stock por
Almacén</button>
</div>
<!-- Warehouses Tab -->
<div v-if="activeTab === 'warehouses'" class="mt-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="wh in warehouses" :key="wh.id" class="card overflow-hidden">
<div class="card-body p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-2">
<span class="icon">W</span>
<div>
<h3 class="font-semibold text-sm">{{ wh.name }}</h3>
<p class="text-xs text-muted-foreground font-mono">{{ wh.code }}</p>
</div>
</div>
<span
:class="['badge', wh.status === 'active' ? 'badge-default' : 'badge-destructive']">{{
wh.status === 'active' ? 'Activo' : 'Lleno' }}</span>
</div>
<div class="flex items-center space-x-1 text-xs text-muted-foreground mb-3">
<span class="icon">📍</span>
<span>{{ wh.location }}</span>
</div>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Productos:</span>
<span class="font-medium">{{ wh.totalProducts }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Valor:</span>
<span class="font-medium">{{ (wh.totalValue / 1000000).toFixed(1) }}M</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Capacidad:</span>
<span
:class="['font-medium', parseCapacity(wh.capacity) > 80 ? 'text-destructive' : parseCapacity(wh.capacity) > 60 ? 'text-warning' : 'text-success']">{{
wh.capacity }}</span>
</div>
<div class="w-full bg-muted rounded-full h-2 mt-2">
<div :class="['h-2 rounded-full transition-all', parseCapacity(wh.capacity) > 80 ? 'bg-destructive' : parseCapacity(wh.capacity) > 60 ? 'bg-warning' : 'bg-success']"
:style="{ width: wh.capacity }" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Movements Tab -->
<div v-if="activeTab === 'movements'" class="mt-4">
<div class="card">
<div class="card-body p-4">
<div class="flex items-center space-x-4 mb-6">
<div class="relative flex-1 max-w-sm">
<Searcher title="Buscar movimientos..." placeholder="Buscar movimientos..."
@search="val => searchTerm = val" />
<input class="hidden" />
</div>
</div>
<div class="rounded-md border">
<table class="w-full">
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Producto</th>
<th>Cantidad</th>
<th>Almacén</th>
<th>Usuario</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
<tr v-for="movement in filteredMovements" :key="movement.id">
<td class="text-sm">{{ new Date(movement.date).toLocaleString('es-MX') }}</td>
<td>
<div class="flex items-center space-x-2">
<span :class="getMovementIconClass(movement.type)"></span>
<span class="badge" :data-variant="movementVariant(movement.type)">{{
movement.type }}</span>
</div>
</td>
<td>
<div>
<p class="font-medium text-sm">{{ movement.product }}</p>
<p class="text-xs text-muted-foreground font-mono">{{ movement.code }}
</p>
</div>
</td>
<td class="font-mono">
<span
:class="movement.quantity > 0 ? 'text-success' : 'text-destructive'">{{
movement.quantity > 0 ? '+' : '' }}{{ movement.quantity }}</span>
</td>
<td class="text-sm">{{ movement.warehouse }}</td>
<td class="text-sm">{{ movement.user }}</td>
<td>
<span class="badge"
:data-variant="movement.status === 'completed' ? 'default' : 'secondary'">{{
movement.status === 'completed' ? 'Completado' : 'Pendiente' }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Stock by Warehouse Tab -->
<div v-if="activeTab === 'stock'" class="mt-4 space-y-4">
<div v-for="(wh, index) in stockByWarehouse" :key="index" class="card">
<div class="card-body">
<div class="flex items-center space-x-2 mb-2">
<span class="icon">W</span>
<h4 class="font-semibold">{{ wh.warehouse }}</h4>
</div>
<div class="rounded-md border">
<table class="w-full">
<thead>
<tr>
<th>Código</th>
<th>Producto</th>
<th>Stock</th>
<th>Valor</th>
</tr>
</thead>
<tbody>
<tr v-for="product in wh.products" :key="product.code">
<td class="font-mono text-sm">{{ product.code }}</td>
<td class="font-medium">{{ product.name }}</td>
<td class="font-mono">{{ product.stock }} uds</td>
<td class="font-mono">{{ formatCurrency(product.value) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { lang } from '@Lang/i18n';
// Ruta API
const apiTo = (name, params = {}) => route(`warehouses.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `warehouses.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`warehouses.${str}`)
export {
viewTo,
apiTo,
transl
}

View File

@ -306,6 +306,43 @@ const router = createRouter({
}
]
},
{
path: 'warehouses',
name: 'admin.warehouses',
meta: {
title: 'Bodegas',
icon: 'inventory_2',
},
redirect: '/admin/warehouses',
children: [
{
path: '',
name: 'admin.warehouses.index',
component: () => import('@Pages/Warehouses/Index.vue'),
},
{
path: 'create',
name: 'admin.warehouses.create',
component: () => import('@Pages/Admin/Roles/Index.vue'),
meta: {
title: 'Crear',
icon: 'add',
},
},
{
path: ':id/edit',
name: 'admin.warehouses.edit',
component: () => import('@Pages/Admin/Roles/Index.vue'),
meta: {
title: 'Editar',
icon: 'edit',
},
}
]
},
{
path: 'roles',
name: 'admin.roles',