feat: restructure project for GOLS Control Frontend
- Updated README.md to reflect new project structure and conventions. - Refactored component imports and paths to align with new layout. - Removed legacy components (AppConfig.vue, AppTopbar.vue) and created new layout components (MainLayout.vue, Sidebar.vue, TopBar.vue). - Implemented warehouse module with components for inventory management (WarehouseDashboard.vue, InventoryTable.vue). - Added composables and services for warehouse logic and API interactions. - Introduced shared components (KpiCard.vue) for KPI display. - Enhanced API service for handling HTTP requests. - Defined TypeScript types for warehouse entities and global application types.
This commit is contained in:
parent
47e43ae84e
commit
06c212821a
118
README.md
118
README.md
@ -1,5 +1,117 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
# Estructura del Proyecto - GOLS Control Frontend
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
## 📁 Estructura de Carpetas
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
```
|
||||
src/
|
||||
├── App.vue # Componente raíz (cambia entre demos)
|
||||
├── main.ts # Punto de entrada
|
||||
├── MainLayout.vue # Layout principal con Sidebar + TopBar
|
||||
├── ColorDemo.vue # Demo de personalización de colores
|
||||
│
|
||||
├── assets/ # Recursos estáticos
|
||||
│ └── styles/
|
||||
│ └── main.css # Estilos globales y variables CSS
|
||||
│
|
||||
├── components/ # Componentes globales
|
||||
│ ├── layout/ # Componentes de layout
|
||||
│ │ ├── TopBar.vue # Barra superior
|
||||
│ │ ├── Sidebar.vue # Menú lateral
|
||||
│ │ └── AppConfig.vue # Panel de configuración de colores
|
||||
│ │
|
||||
│ ├── shared/ # Componentes compartidos
|
||||
│ │ └── KpiCard.vue # Tarjeta de KPI
|
||||
│ │
|
||||
│ ├── ui/ # Componentes UI reutilizables
|
||||
│ │ └── (vacío por ahora)
|
||||
│ │
|
||||
│ └── Holos/ # (Legacy - se puede eliminar)
|
||||
│ ├── AppTopbar.vue
|
||||
│ └── AppConfig.vue
|
||||
│
|
||||
├── composables/ # Composables globales
|
||||
│ └── useLayout.ts # Gestión de tema y colores
|
||||
│
|
||||
├── modules/ # Módulos de negocio
|
||||
│ └── warehouse/ # Módulo de almacén
|
||||
│ ├── components/
|
||||
│ │ ├── WarehouseDashboard.vue # Dashboard principal
|
||||
│ │ └── InventoryTable.vue # Tabla de inventario
|
||||
│ │
|
||||
│ ├── composables/
|
||||
│ │ └── useWarehouse.ts # Lógica de negocio
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ └── warehouseService.ts # API del módulo
|
||||
│ │
|
||||
│ └── types/
|
||||
│ └── warehouse.d.ts # Tipos TypeScript
|
||||
│
|
||||
├── services/ # Servicios globales
|
||||
│ └── api.ts # Cliente HTTP base
|
||||
│
|
||||
└── types/ # Tipos globales
|
||||
└── global.d.ts # Definiciones TypeScript globales
|
||||
```
|
||||
|
||||
## 🎯 Convenciones
|
||||
|
||||
### Módulos
|
||||
Cada módulo sigue la estructura:
|
||||
```
|
||||
modules/[nombre-modulo]/
|
||||
├── components/ # Componentes específicos del módulo
|
||||
├── composables/ # Lógica de negocio del módulo
|
||||
├── services/ # API calls del módulo
|
||||
└── types/ # Types específicos del módulo
|
||||
```
|
||||
|
||||
### Componentes
|
||||
- **Layout**: Componentes de estructura (TopBar, Sidebar, etc.)
|
||||
- **Shared**: Componentes reutilizables entre módulos
|
||||
- **UI**: Componentes de interfaz básicos
|
||||
|
||||
### Composables
|
||||
- Prefijo `use` (ej: `useWarehouse`, `useLayout`)
|
||||
- Encapsulan lógica reutilizable
|
||||
- Retornan estado reactivo y métodos
|
||||
|
||||
### Services
|
||||
- Manejan comunicaciones HTTP
|
||||
- Retornan Promises
|
||||
- Usan el cliente `api.ts` base
|
||||
|
||||
## 🚀 Uso
|
||||
|
||||
### Ver Demo de Colores
|
||||
```ts
|
||||
// En App.vue
|
||||
const currentView = ref<'color' | 'warehouse'>('color');
|
||||
```
|
||||
|
||||
### Ver Dashboard de Almacén
|
||||
```ts
|
||||
// En App.vue
|
||||
const currentView = ref<'color' | 'warehouse'>('warehouse');
|
||||
```
|
||||
|
||||
## 📦 Crear un Nuevo Módulo
|
||||
|
||||
1. Crear estructura en `src/modules/[nombre]/`
|
||||
2. Definir tipos en `types/[nombre].d.ts`
|
||||
3. Crear servicio en `services/[nombre]Service.ts`
|
||||
4. Crear composable en `composables/use[Nombre].ts`
|
||||
5. Crear componentes en `components/`
|
||||
|
||||
## 🎨 Sistema de Colores
|
||||
|
||||
- Color primario: **Azul** (fijo)
|
||||
- Colores de superficie: **5 opciones** (slate, gray, zinc, neutral, stone)
|
||||
- Modo oscuro: Activable desde TopBar
|
||||
|
||||
## 📝 Notas
|
||||
|
||||
- Los componentes de PrimeVue se auto-importan
|
||||
- TypeScript configurado con strict mode
|
||||
- Tailwind CSS v4 integrado
|
||||
- Variables CSS personalizadas en `main.css`
|
||||
|
||||
8
components.d.ts
vendored
8
components.d.ts
vendored
@ -11,10 +11,16 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppConfig: typeof import('./src/components/Holos/AppConfig.vue')['default']
|
||||
AppConfig: typeof import('./src/components/layout/AppConfig.vue')['default']
|
||||
AppTopbar: typeof import('./src/components/Holos/AppTopbar.vue')['default']
|
||||
Button: typeof import('primevue/button')['default']
|
||||
Column: typeof import('primevue/column')['default']
|
||||
DataTable: typeof import('primevue/datatable')['default']
|
||||
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||
KpiCard: typeof import('./src/components/shared/KpiCard.vue')['default']
|
||||
Sidebar: typeof import('./src/components/layout/Sidebar.vue')['default']
|
||||
Tag: typeof import('primevue/tag')['default']
|
||||
TopBar: typeof import('./src/components/layout/TopBar.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
StyleClass: typeof import('primevue/styleclass')['default']
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import ColorDemo from './ColorDemo.vue';
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import MainLayout from './MainLayout.vue';
|
||||
|
||||
// Cambiar entre 'color' y 'warehouse' para ver diferentes demos
|
||||
const currentView = ref<'color' | 'warehouse'>('warehouse');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ColorDemo />
|
||||
<ColorDemo v-if="currentView === 'color'" />
|
||||
<MainLayout v-else />
|
||||
</template>
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import AppTopbar from './components/Holos/AppTopbar.vue';
|
||||
import TopBar from './components/layout/TopBar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-surface-50 dark:bg-surface-950">
|
||||
<AppTopbar />
|
||||
<TopBar />
|
||||
|
||||
<div class="p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
|
||||
23
src/MainLayout.vue
Normal file
23
src/MainLayout.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import TopBar from './components/layout/TopBar.vue';
|
||||
import Sidebar from './components/layout/Sidebar.vue';
|
||||
import WarehouseDashboard from './modules/warehouse/components/WarehouseDashboard.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen bg-surface-50 dark:bg-surface-950">
|
||||
<!-- Sidebar -->
|
||||
<Sidebar />
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<!-- TopBar -->
|
||||
<TopBar />
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
<WarehouseDashboard />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -9,26 +9,6 @@ const { surfaces, surface, updateColors } = useLayout();
|
||||
class="absolute top-16 right-0 w-64 p-4 bg-white dark:bg-surface-900 rounded-md shadow-lg border border-surface-200 dark:border-surface-700 origin-top z-50 hidden"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Selector de color primario oculto ya que solo hay uno -->
|
||||
<!-- <div>
|
||||
<span class="text-sm text-surface-600 dark:text-surface-400 font-semibold">Color Primario</span>
|
||||
<div class="pt-2 flex gap-2 flex-wrap justify-between">
|
||||
<button
|
||||
v-for="pc of primaryColors"
|
||||
:key="pc.name"
|
||||
type="button"
|
||||
:title="pc.name"
|
||||
:class="[
|
||||
'border-none w-5 h-5 rounded-full p-0 cursor-pointer focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
{
|
||||
'ring-2 ring-offset-2 ring-surface-950 dark:ring-surface-0': primary === pc.name,
|
||||
},
|
||||
]"
|
||||
:style="{ backgroundColor: pc.palette['500'] }"
|
||||
@click="updateColors('primary', pc.name)"
|
||||
/>
|
||||
</div>
|
||||
</div> -->
|
||||
<div>
|
||||
<span class="text-sm text-surface-600 dark:text-surface-400 font-semibold">Color de Superficie</span>
|
||||
<div class="pt-2 flex gap-2 flex-wrap justify-between">
|
||||
118
src/components/layout/Sidebar.vue
Normal file
118
src/components/layout/Sidebar.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
to?: string;
|
||||
items?: MenuItem[];
|
||||
}
|
||||
|
||||
const menuItems = ref<MenuItem[]>([
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-home',
|
||||
to: '/'
|
||||
},
|
||||
{
|
||||
label: 'Almacén',
|
||||
icon: 'pi pi-warehouse',
|
||||
items: [
|
||||
{ label: 'Inventario', icon: 'pi pi-box', to: '/warehouse/inventory' },
|
||||
{ label: 'Movimientos', icon: 'pi pi-arrow-right-arrow-left', to: '/warehouse/movements' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Configuración',
|
||||
icon: 'pi pi-cog',
|
||||
to: '/settings'
|
||||
}
|
||||
]);
|
||||
|
||||
const sidebarVisible = ref(true);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarVisible.value = !sidebarVisible.value;
|
||||
};
|
||||
|
||||
defineExpose({ toggleSidebar });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
:class="[
|
||||
'bg-surface-0 dark:bg-surface-900 border-r border-surface-200 dark:border-surface-700 transition-all duration-300',
|
||||
sidebarVisible ? 'w-64' : 'w-20'
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Logo / Brand -->
|
||||
<div class="p-4 border-b border-surface-200 dark:border-surface-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="pi pi-box text-2xl text-primary"></i>
|
||||
<span
|
||||
v-if="sidebarVisible"
|
||||
class="text-lg font-semibold text-surface-900 dark:text-surface-0"
|
||||
>
|
||||
GOLS Control
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Menu -->
|
||||
<nav class="flex-1 overflow-y-auto p-3">
|
||||
<ul class="space-y-1">
|
||||
<li v-for="item in menuItems" :key="item.label">
|
||||
<!-- Item sin subitems -->
|
||||
<a
|
||||
v-if="!item.items"
|
||||
:href="item.to"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
|
||||
>
|
||||
<i :class="item.icon"></i>
|
||||
<span v-if="sidebarVisible">{{ item.label }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Item con subitems -->
|
||||
<div v-else>
|
||||
<div class="flex items-center gap-3 px-3 py-2 text-surface-700 dark:text-surface-200 font-medium">
|
||||
<i :class="item.icon"></i>
|
||||
<span v-if="sidebarVisible">{{ item.label }}</span>
|
||||
</div>
|
||||
<ul v-if="sidebarVisible" class="ml-6 mt-1 space-y-1">
|
||||
<li v-for="subItem in item.items" :key="subItem.label">
|
||||
<a
|
||||
:href="subItem.to"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-sm"
|
||||
>
|
||||
<i :class="subItem.icon" class="text-xs"></i>
|
||||
<span>{{ subItem.label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Toggle Button -->
|
||||
<div class="p-3 border-t border-surface-200 dark:border-surface-700">
|
||||
<button
|
||||
@click="toggleSidebar"
|
||||
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
|
||||
>
|
||||
<i :class="sidebarVisible ? 'pi pi-angle-left' : 'pi pi-angle-right'"></i>
|
||||
<span v-if="sidebarVisible">Contraer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
aside {
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
||||
@ -10,7 +10,7 @@ const { isDarkMode, toggleDarkMode } = useLayout();
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl font-semibold text-surface-900 dark:text-surface-0">
|
||||
Mi Aplicación
|
||||
GOLS Control
|
||||
</span>
|
||||
</div>
|
||||
|
||||
68
src/components/shared/KpiCard.vue
Normal file
68
src/components/shared/KpiCard.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'primary'
|
||||
});
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
primary: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
|
||||
success: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
|
||||
warning: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
|
||||
danger: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
|
||||
info: 'bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400'
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-surface-0 dark:bg-surface-900 rounded-lg p-6 border border-surface-200 dark:border-surface-700">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
|
||||
{{ title }}
|
||||
</p>
|
||||
<h3 class="text-3xl font-bold text-surface-900 dark:text-surface-0">
|
||||
{{ value }}
|
||||
</h3>
|
||||
|
||||
<!-- Trend indicator -->
|
||||
<div v-if="trend" class="flex items-center gap-1 mt-2">
|
||||
<i
|
||||
:class="[
|
||||
'text-sm',
|
||||
trend.isPositive ? 'pi pi-arrow-up text-green-500' : 'pi pi-arrow-down text-red-500'
|
||||
]"
|
||||
></i>
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
trend.isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ Math.abs(trend.value) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div
|
||||
v-if="icon"
|
||||
:class="[
|
||||
'w-12 h-12 rounded-lg flex items-center justify-center',
|
||||
colorClasses[color]
|
||||
]"
|
||||
>
|
||||
<i :class="[icon, 'text-xl']"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
130
src/modules/warehouse/components/InventoryTable.vue
Normal file
130
src/modules/warehouse/components/InventoryTable.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useWarehouse } from '../composables/useWarehouse';
|
||||
import type { Product } from '../types/warehouse';
|
||||
|
||||
const { products, loading, loadProducts } = useWarehouse();
|
||||
|
||||
const selectedProducts = ref<Product[]>([]);
|
||||
const searchQuery = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts();
|
||||
});
|
||||
|
||||
const getStockStatus = (product: Product) => {
|
||||
if (product.quantity === 0) return { label: 'Sin Stock', severity: 'danger' };
|
||||
if (product.quantity < product.minStock) return { label: 'Stock Bajo', severity: 'warning' };
|
||||
if (product.quantity > product.maxStock) return { label: 'Sobrestock', severity: 'info' };
|
||||
return { label: 'Normal', severity: 'success' };
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(new Date(date));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="relative">
|
||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-surface-400"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Buscar productos..."
|
||||
class="w-full pl-10 pr-4 py-2 rounded-lg border border-surface-300 dark:border-surface-600 bg-surface-0 dark:bg-surface-800 text-surface-900 dark:text-surface-0 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DataTable using PrimeVue -->
|
||||
<DataTable
|
||||
v-model:selection="selectedProducts"
|
||||
:value="products"
|
||||
:loading="loading"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[5, 10, 20, 50]"
|
||||
:globalFilterFields="['name', 'sku', 'category']"
|
||||
:globalFilter="searchQuery"
|
||||
stripedRows
|
||||
showGridlines
|
||||
class="text-sm"
|
||||
>
|
||||
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
||||
|
||||
<Column field="sku" header="SKU" sortable>
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs">{{ data.sku }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="name" header="Producto" sortable>
|
||||
<template #body="{ data }">
|
||||
<div>
|
||||
<div class="font-medium text-surface-900 dark:text-surface-0">{{ data.name }}</div>
|
||||
<div class="text-xs text-surface-500">{{ data.category }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="location" header="Ubicación" sortable></Column>
|
||||
|
||||
<Column field="quantity" header="Cantidad" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{{ data.quantity }}</span>
|
||||
<Tag
|
||||
:value="getStockStatus(data).label"
|
||||
:severity="getStockStatus(data).severity"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="minStock" header="Stock Mín." sortable>
|
||||
<template #body="{ data }">
|
||||
<span class="text-surface-600 dark:text-surface-400">{{ data.minStock }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="unitPrice" header="Precio Unit." sortable>
|
||||
<template #body="{ data }">
|
||||
<span class="font-medium">{{ formatCurrency(data.unitPrice) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="lastUpdated" header="Última Actualización" sortable>
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs text-surface-500">{{ formatDate(data.lastUpdated) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Acciones">
|
||||
<template #body>
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-pencil" size="small" text rounded />
|
||||
<Button icon="pi pi-trash" size="small" text rounded severity="danger" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Estilos adicionales si es necesario */
|
||||
</style>
|
||||
74
src/modules/warehouse/components/WarehouseDashboard.vue
Normal file
74
src/modules/warehouse/components/WarehouseDashboard.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useWarehouse } from '../composables/useWarehouse';
|
||||
import KpiCard from '../../../components/shared/KpiCard.vue';
|
||||
import InventoryTable from './InventoryTable.vue';
|
||||
|
||||
const { stats, loadStats, loadProducts } = useWarehouse();
|
||||
|
||||
onMounted(async () => {
|
||||
await loadStats();
|
||||
await loadProducts();
|
||||
});
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-0 mb-2">
|
||||
Dashboard de Almacén
|
||||
</h1>
|
||||
<p class="text-surface-600 dark:text-surface-400">
|
||||
Visión general del inventario y movimientos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<KpiCard
|
||||
title="Total de Productos"
|
||||
:value="stats?.totalProducts ?? 0"
|
||||
icon="pi pi-box"
|
||||
color="primary"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Valor del Inventario"
|
||||
:value="stats ? formatCurrency(stats.totalValue) : '$0'"
|
||||
icon="pi pi-dollar"
|
||||
color="success"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Productos con Stock Bajo"
|
||||
:value="stats?.lowStockItems ?? 0"
|
||||
icon="pi pi-exclamation-triangle"
|
||||
color="warning"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Movimientos Recientes"
|
||||
:value="stats?.recentMovements ?? 0"
|
||||
icon="pi pi-arrow-right-arrow-left"
|
||||
color="info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Inventory Table -->
|
||||
<div class="bg-surface-0 dark:bg-surface-900 rounded-lg border border-surface-200 dark:border-surface-700">
|
||||
<div class="p-6 border-b border-surface-200 dark:border-surface-700">
|
||||
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-0">
|
||||
Inventario
|
||||
</h2>
|
||||
</div>
|
||||
<InventoryTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
82
src/modules/warehouse/composables/useWarehouse.ts
Normal file
82
src/modules/warehouse/composables/useWarehouse.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Product, WarehouseStats } from '../types/warehouse';
|
||||
import { warehouseService } from '../services/warehouseService';
|
||||
|
||||
export function useWarehouse() {
|
||||
const products = ref<Product[]>([]);
|
||||
const stats = ref<WarehouseStats | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Computed properties
|
||||
const lowStockProducts = computed(() =>
|
||||
products.value.filter(p => p.quantity < p.minStock)
|
||||
);
|
||||
|
||||
const totalInventoryValue = computed(() =>
|
||||
products.value.reduce((sum, p) => sum + (p.quantity * p.unitPrice), 0)
|
||||
);
|
||||
|
||||
// Methods
|
||||
const loadProducts = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
products.value = await warehouseService.getProducts();
|
||||
} catch (err) {
|
||||
error.value = 'Error al cargar productos';
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
stats.value = await warehouseService.getStats();
|
||||
} catch (err) {
|
||||
error.value = 'Error al cargar estadísticas';
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateQuantity = async (productId: string, quantity: number) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const updated = await warehouseService.updateProductQuantity(productId, quantity);
|
||||
if (updated) {
|
||||
const index = products.value.findIndex(p => p.id === productId);
|
||||
if (index !== -1) {
|
||||
products.value[index] = updated;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Error al actualizar cantidad';
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
products,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
lowStockProducts,
|
||||
totalInventoryValue,
|
||||
|
||||
// Methods
|
||||
loadProducts,
|
||||
loadStats,
|
||||
updateQuantity
|
||||
};
|
||||
}
|
||||
152
src/modules/warehouse/services/warehouseService.ts
Normal file
152
src/modules/warehouse/services/warehouseService.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import type { Product, WarehouseMovement, WarehouseStats } from '../types/warehouse';
|
||||
|
||||
// Simulación de datos de ejemplo
|
||||
const mockProducts: Product[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Laptop Dell XPS 15',
|
||||
sku: 'LAP-001',
|
||||
category: 'Electrónica',
|
||||
quantity: 45,
|
||||
minStock: 10,
|
||||
maxStock: 100,
|
||||
unitPrice: 1299.99,
|
||||
location: 'A-1-01',
|
||||
lastUpdated: new Date('2025-11-01')
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Mouse Logitech MX Master',
|
||||
sku: 'MOU-001',
|
||||
category: 'Accesorios',
|
||||
quantity: 8,
|
||||
minStock: 15,
|
||||
maxStock: 50,
|
||||
unitPrice: 99.99,
|
||||
location: 'B-2-03',
|
||||
lastUpdated: new Date('2025-11-03')
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Teclado Mecánico RGB',
|
||||
sku: 'KEY-001',
|
||||
category: 'Accesorios',
|
||||
quantity: 120,
|
||||
minStock: 20,
|
||||
maxStock: 150,
|
||||
unitPrice: 149.99,
|
||||
location: 'B-2-05',
|
||||
lastUpdated: new Date('2025-11-04')
|
||||
}
|
||||
];
|
||||
|
||||
const mockMovements: WarehouseMovement[] = [
|
||||
{
|
||||
id: '1',
|
||||
productId: '1',
|
||||
productName: 'Laptop Dell XPS 15',
|
||||
type: 'in',
|
||||
quantity: 20,
|
||||
reason: 'Compra a proveedor',
|
||||
date: new Date('2025-11-01'),
|
||||
user: 'Admin'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
productId: '2',
|
||||
productName: 'Mouse Logitech MX Master',
|
||||
type: 'out',
|
||||
quantity: 5,
|
||||
reason: 'Venta',
|
||||
date: new Date('2025-11-03'),
|
||||
user: 'Admin'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Servicio para gestionar operaciones del almacén
|
||||
*/
|
||||
export const warehouseService = {
|
||||
/**
|
||||
* Obtiene todos los productos del inventario
|
||||
*/
|
||||
async getProducts(): Promise<Product[]> {
|
||||
// Simular llamada API
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(mockProducts), 500);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene un producto por ID
|
||||
*/
|
||||
async getProductById(id: string): Promise<Product | null> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const product = mockProducts.find(p => p.id === id);
|
||||
resolve(product || null);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene los movimientos del almacén
|
||||
*/
|
||||
async getMovements(): Promise<WarehouseMovement[]> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(mockMovements), 500);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene las estadísticas del almacén
|
||||
*/
|
||||
async getStats(): Promise<WarehouseStats> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const stats: WarehouseStats = {
|
||||
totalProducts: mockProducts.reduce((sum, p) => sum + p.quantity, 0),
|
||||
totalValue: mockProducts.reduce((sum, p) => sum + (p.quantity * p.unitPrice), 0),
|
||||
lowStockItems: mockProducts.filter(p => p.quantity < p.minStock).length,
|
||||
recentMovements: mockMovements.length
|
||||
};
|
||||
resolve(stats);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Crea un nuevo movimiento de inventario
|
||||
*/
|
||||
async createMovement(movement: Omit<WarehouseMovement, 'id' | 'date'>): Promise<WarehouseMovement> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const newMovement: WarehouseMovement = {
|
||||
...movement,
|
||||
id: String(mockMovements.length + 1),
|
||||
date: new Date()
|
||||
};
|
||||
mockMovements.push(newMovement);
|
||||
resolve(newMovement);
|
||||
}, 500);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualiza la cantidad de un producto
|
||||
*/
|
||||
async updateProductQuantity(productId: string, quantity: number): Promise<Product | null> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const product = mockProducts.find(p => p.id === productId);
|
||||
if (product) {
|
||||
product.quantity = quantity;
|
||||
product.lastUpdated = new Date();
|
||||
resolve(product);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
};
|
||||
37
src/modules/warehouse/types/warehouse.d.ts
vendored
Normal file
37
src/modules/warehouse/types/warehouse.d.ts
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
// Types para el módulo Warehouse
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
minStock: number;
|
||||
maxStock: number;
|
||||
unitPrice: number;
|
||||
location: string;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface WarehouseMovement {
|
||||
id: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
type: 'in' | 'out' | 'adjustment';
|
||||
quantity: number;
|
||||
reason: string;
|
||||
date: Date;
|
||||
user: string;
|
||||
}
|
||||
|
||||
export interface WarehouseStats {
|
||||
totalProducts: number;
|
||||
totalValue: number;
|
||||
lowStockItems: number;
|
||||
recentMovements: number;
|
||||
}
|
||||
|
||||
export interface InventoryFilter {
|
||||
search?: string;
|
||||
category?: string;
|
||||
lowStock?: boolean;
|
||||
}
|
||||
120
src/services/api.ts
Normal file
120
src/services/api.ts
Normal file
@ -0,0 +1,120 @@
|
||||
// Servicio API base para todas las peticiones HTTP
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye la URL con query params
|
||||
*/
|
||||
private buildUrl(endpoint: string, params?: Record<string, string>): string {
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja la respuesta de la API
|
||||
*/
|
||||
private async handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Error desconocido' }));
|
||||
throw new Error(error.message || `HTTP Error: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
async get<T>(endpoint: string, options?: RequestOptions): Promise<T> {
|
||||
const url = this.buildUrl(endpoint, options?.params);
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post<T>(endpoint: string, data?: any, options?: RequestOptions): Promise<T> {
|
||||
const url = this.buildUrl(endpoint, options?.params);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put<T>(endpoint: string, data?: any, options?: RequestOptions): Promise<T> {
|
||||
const url = this.buildUrl(endpoint, options?.params);
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete<T>(endpoint: string, options?: RequestOptions): Promise<T> {
|
||||
const url = this.buildUrl(endpoint, options?.params);
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request
|
||||
*/
|
||||
async patch<T>(endpoint: string, data?: any, options?: RequestOptions): Promise<T> {
|
||||
const url = this.buildUrl(endpoint, options?.params);
|
||||
const response = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
}
|
||||
|
||||
// Exportar instancia singleton
|
||||
export const api = new ApiService(API_BASE_URL);
|
||||
39
src/types/global.d.ts
vendored
Normal file
39
src/types/global.d.ts
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
// Tipos globales de la aplicación
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface TableColumn {
|
||||
field: string;
|
||||
header: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
}
|
||||
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
|
||||
export interface SortOptions {
|
||||
field: string;
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
[key: string]: any;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user