WIP
This commit is contained in:
parent
8079470f22
commit
c95d40787d
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
386
src/pages/Warehouses/Index.vue
Normal file
386
src/pages/Warehouses/Index.vue
Normal 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>
|
||||
16
src/pages/Warehouses/Module.js
Normal file
16
src/pages/Warehouses/Module.js
Normal 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
|
||||
}
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user