Initial commit: Estructura backend y frontend con estándar VPS
- Backend migrado a estructura VPS (src/ subfolder) - Frontend con estructura Vite + React 19 + Tailwind - Configuración PostgreSQL con Pool - API service con interceptores JWT - Ambos servidores funcionando (backend:3001, frontend:5173)
This commit is contained in:
849
PLANIFICACION.md
Normal file
849
PLANIFICACION.md
Normal file
@@ -0,0 +1,849 @@
|
||||
# 🚀 PLANIFICACIÓN - OFERTAWEB.CL
|
||||
## Sistema E-Commerce Multi-Canal con Gestión de Despacho
|
||||
|
||||
**Fecha:** Diciembre 2025
|
||||
**Versión:** 1.0
|
||||
|
||||
---
|
||||
|
||||
## 📁 ESTRUCTURA DEL PROYECTO
|
||||
|
||||
```
|
||||
ofertaweb.cl/
|
||||
├── backend/ # API REST (Node.js + Express + PostgreSQL)
|
||||
├── frontend/ # Tienda web para clientes (React/Vue)
|
||||
└── administracion/ # Panel administrativo (React/Vue)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 OBJETIVOS DEL SISTEMA
|
||||
|
||||
### Funcionalidades Core
|
||||
1. ✅ Catálogo de productos con categorías
|
||||
2. ✅ Carrito de compras y proceso de checkout
|
||||
3. ✅ Gestión de órdenes y despacho (tracking completo)
|
||||
4. ✅ Panel administrativo completo
|
||||
5. ✅ Sistema de stock unificado en tiempo real
|
||||
|
||||
### Integraciones Externas
|
||||
1. 🔗 **MercadoLibre** - Publicación automática (precio +10%)
|
||||
2. 🔗 **Instagram Shopping** - Sincronización de catálogo
|
||||
3. 🔗 **BlueExpress** - Cálculo de envío en tiempo real
|
||||
4. 🔗 **Pasarela de Pagos** - Webpay/Transbank (por definir)
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ ARQUITECTURA DE BASE DE DATOS
|
||||
|
||||
### Módulo: Usuarios y Autenticación
|
||||
```sql
|
||||
users
|
||||
├── id (PK)
|
||||
├── email (unique)
|
||||
├── password_hash
|
||||
├── nombre
|
||||
├── apellido
|
||||
├── telefono
|
||||
├── role (enum: 'cliente', 'admin', 'repartidor')
|
||||
├── is_active
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
user_addresses
|
||||
├── id (PK)
|
||||
├── user_id (FK → users)
|
||||
├── direccion
|
||||
├── comuna
|
||||
├── ciudad
|
||||
├── region
|
||||
├── codigo_postal
|
||||
├── referencia
|
||||
├── is_default
|
||||
└── created_at
|
||||
```
|
||||
|
||||
### Módulo: Catálogo de Productos
|
||||
```sql
|
||||
categories
|
||||
├── id (PK)
|
||||
├── nombre
|
||||
├── slug
|
||||
├── descripcion
|
||||
├── parent_id (FK → categories, nullable)
|
||||
├── orden
|
||||
└── is_active
|
||||
|
||||
products
|
||||
├── id (PK)
|
||||
├── sku (unique)
|
||||
├── nombre
|
||||
├── descripcion
|
||||
├── precio_base
|
||||
├── peso_gramos
|
||||
├── alto_cm
|
||||
├── ancho_cm
|
||||
├── largo_cm
|
||||
├── category_id (FK → categories)
|
||||
├── is_active
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
product_images
|
||||
├── id (PK)
|
||||
├── product_id (FK → products)
|
||||
├── url
|
||||
├── alt_text
|
||||
├── orden
|
||||
└── is_primary
|
||||
```
|
||||
|
||||
### Módulo: Multi-Canal
|
||||
```sql
|
||||
channels
|
||||
├── id (PK)
|
||||
├── nombre (enum: 'tienda', 'mercadolibre', 'instagram')
|
||||
├── slug
|
||||
├── config (jsonb) -- credenciales, tokens
|
||||
├── is_active
|
||||
└── updated_at
|
||||
|
||||
channel_products
|
||||
├── id (PK)
|
||||
├── product_id (FK → products)
|
||||
├── channel_id (FK → channels)
|
||||
├── external_id (ID en plataforma externa)
|
||||
├── precio_ajustado (precio_base * factor)
|
||||
├── precio_factor (ej: 1.10 para ML)
|
||||
├── estado (enum: 'publicado', 'pausado', 'agotado')
|
||||
├── last_sync_at
|
||||
└── sync_errors (jsonb)
|
||||
|
||||
channel_sync_logs
|
||||
├── id (PK)
|
||||
├── channel_id (FK → channels)
|
||||
├── product_id (FK → products, nullable)
|
||||
├── tipo (enum: 'stock', 'precio', 'publicacion')
|
||||
├── status (enum: 'success', 'error')
|
||||
├── mensaje
|
||||
└── created_at
|
||||
```
|
||||
|
||||
### Módulo: Inventario (Stock Unificado)
|
||||
```sql
|
||||
inventory
|
||||
├── id (PK)
|
||||
├── product_id (FK → products, unique)
|
||||
├── stock_actual
|
||||
├── stock_minimo
|
||||
├── stock_reservado
|
||||
└── updated_at
|
||||
|
||||
inventory_movements
|
||||
├── id (PK)
|
||||
├── product_id (FK → products)
|
||||
├── tipo (enum: 'entrada', 'salida', 'ajuste', 'reserva', 'liberacion')
|
||||
├── cantidad
|
||||
├── cantidad_anterior
|
||||
├── cantidad_nueva
|
||||
├── referencia (ej: order_id, ajuste manual)
|
||||
├── notas
|
||||
├── created_by (FK → users)
|
||||
└── created_at
|
||||
```
|
||||
|
||||
### Módulo: Carrito de Compras
|
||||
```sql
|
||||
carts
|
||||
├── id (PK)
|
||||
├── user_id (FK → users, nullable para invitados)
|
||||
├── session_id (para invitados)
|
||||
├── expires_at
|
||||
└── created_at
|
||||
|
||||
cart_items
|
||||
├── id (PK)
|
||||
├── cart_id (FK → carts)
|
||||
├── product_id (FK → products)
|
||||
├── cantidad
|
||||
├── precio_unitario (precio al momento de agregar)
|
||||
└── created_at
|
||||
```
|
||||
|
||||
### Módulo: Órdenes de Compra
|
||||
```sql
|
||||
orders
|
||||
├── id (PK)
|
||||
├── order_number (unique, ej: ORD-20251205-0001)
|
||||
├── user_id (FK → users)
|
||||
├── channel_id (FK → channels) -- origen de la venta
|
||||
├── estado (enum: 'pendiente_pago', 'pagada', 'preparando', 'enviada', 'entregada', 'cancelada')
|
||||
├── subtotal
|
||||
├── descuento
|
||||
├── costo_envio
|
||||
├── total
|
||||
├── notas_cliente
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
order_items
|
||||
├── id (PK)
|
||||
├── order_id (FK → orders)
|
||||
├── product_id (FK → products)
|
||||
├── cantidad
|
||||
├── precio_unitario
|
||||
├── subtotal
|
||||
└── created_at
|
||||
```
|
||||
|
||||
### Módulo: Pagos
|
||||
```sql
|
||||
payments
|
||||
├── id (PK)
|
||||
├── order_id (FK → orders)
|
||||
├── metodo (enum: 'webpay', 'transferencia', 'mercadopago', 'efectivo')
|
||||
├── estado (enum: 'pendiente', 'aprobado', 'rechazado', 'reembolsado')
|
||||
├── monto
|
||||
├── transaction_id (ID de la pasarela)
|
||||
├── response_data (jsonb)
|
||||
├── paid_at
|
||||
└── created_at
|
||||
|
||||
payment_transactions
|
||||
├── id (PK)
|
||||
├── payment_id (FK → payments)
|
||||
├── tipo (enum: 'authorization', 'capture', 'refund')
|
||||
├── status
|
||||
├── request_data (jsonb)
|
||||
├── response_data (jsonb)
|
||||
└── created_at
|
||||
```
|
||||
|
||||
### Módulo: Despacho y Envíos
|
||||
```sql
|
||||
shipments
|
||||
├── id (PK)
|
||||
├── order_id (FK → orders, unique)
|
||||
├── tracking_number
|
||||
├── courier (enum: 'bluexpress', 'chilexpress', 'starken', 'retiro')
|
||||
├── estado (enum: 'pendiente', 'preparando', 'en_transito', 'en_reparto', 'entregado', 'fallido')
|
||||
├── direccion_id (FK → user_addresses)
|
||||
├── costo_calculado
|
||||
├── peso_total
|
||||
├── dimensiones (jsonb)
|
||||
├── fecha_estimada_entrega
|
||||
├── fecha_entrega_real
|
||||
├── repartidor_id (FK → users, nullable)
|
||||
├── notas_despacho
|
||||
├── comprobante_url (foto/firma)
|
||||
└── created_at
|
||||
|
||||
shipment_tracking
|
||||
├── id (PK)
|
||||
├── shipment_id (FK → shipments)
|
||||
├── estado
|
||||
├── ubicacion
|
||||
├── descripcion
|
||||
├── created_by (FK → users, nullable)
|
||||
└── created_at
|
||||
|
||||
shipping_quotes
|
||||
├── id (PK)
|
||||
├── order_id (FK → orders, nullable)
|
||||
├── courier
|
||||
├── servicio (ej: 'express', 'normal')
|
||||
├── origen_comuna
|
||||
├── destino_comuna
|
||||
├── peso_gramos
|
||||
├── costo
|
||||
├── dias_estimados
|
||||
├── response_data (jsonb)
|
||||
├── expires_at
|
||||
└── created_at
|
||||
```
|
||||
|
||||
### Módulo: Configuración del Sistema
|
||||
```sql
|
||||
settings
|
||||
├── id (PK)
|
||||
├── key (unique, ej: 'ml_client_id', 'instagram_token')
|
||||
├── value (text)
|
||||
├── tipo (enum: 'string', 'number', 'boolean', 'json')
|
||||
├── descripcion
|
||||
├── is_secret (boolean)
|
||||
└── updated_at
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ BACKEND - ESTRUCTURA DE ARCHIVOS
|
||||
|
||||
```
|
||||
backend/
|
||||
├── index.js # Punto de entrada
|
||||
├── package.json
|
||||
├── .env # Variables de entorno
|
||||
├── .env.example
|
||||
│
|
||||
├── config/
|
||||
│ ├── db.js # Conexión PostgreSQL
|
||||
│ ├── constants.js # Constantes del sistema
|
||||
│ └── multer.js # Configuración de uploads
|
||||
│
|
||||
├── middleware/
|
||||
│ ├── auth.js # Verificación JWT
|
||||
│ ├── roleCheck.js # Control de roles
|
||||
│ ├── errorHandler.js # Manejo centralizado de errores
|
||||
│ └── validation.js # Validación de datos (Joi)
|
||||
│
|
||||
├── models/
|
||||
│ ├── User.js
|
||||
│ ├── Product.js
|
||||
│ ├── Category.js
|
||||
│ ├── Order.js
|
||||
│ ├── Shipment.js
|
||||
│ ├── Channel.js
|
||||
│ └── Inventory.js
|
||||
│
|
||||
├── controllers/
|
||||
│ ├── authController.js # Login, registro, recuperar contraseña
|
||||
│ ├── productController.js # CRUD productos
|
||||
│ ├── categoryController.js # CRUD categorías
|
||||
│ ├── cartController.js # Gestión del carrito
|
||||
│ ├── orderController.js # Gestión de órdenes
|
||||
│ ├── shipmentController.js # Gestión de despachos
|
||||
│ ├── inventoryController.js # Control de stock
|
||||
│ ├── channelController.js # Multi-canal
|
||||
│ └── paymentController.js # Procesamiento de pagos
|
||||
│
|
||||
├── routes/
|
||||
│ ├── authRoutes.js
|
||||
│ ├── productRoutes.js
|
||||
│ ├── categoryRoutes.js
|
||||
│ ├── cartRoutes.js
|
||||
│ ├── orderRoutes.js
|
||||
│ ├── shipmentRoutes.js
|
||||
│ ├── inventoryRoutes.js
|
||||
│ ├── channelRoutes.js
|
||||
│ └── paymentRoutes.js
|
||||
│
|
||||
├── services/
|
||||
│ ├── stockService.js # Lógica de sincronización de stock
|
||||
│ ├── pricingService.js # Cálculo de precios por canal
|
||||
│ ├── mercadolibreService.js # Integración MercadoLibre API
|
||||
│ ├── instagramService.js # Integración Instagram/Facebook API
|
||||
│ ├── blueexpressService.js # Integración BlueExpress API
|
||||
│ ├── paymentService.js # Integración pasarela de pagos
|
||||
│ ├── emailService.js # Envío de emails (Nodemailer)
|
||||
│ └── uploadService.js # Manejo de imágenes (Multer + Sharp)
|
||||
│
|
||||
├── utils/
|
||||
│ ├── validators.js # Validadores personalizados
|
||||
│ ├── helpers.js # Funciones auxiliares
|
||||
│ └── logger.js # Sistema de logs
|
||||
│
|
||||
├── jobs/
|
||||
│ ├── syncStockJob.js # Cron: sincronizar stock cada X minutos
|
||||
│ ├── syncPricesJob.js # Cron: actualizar precios en canales
|
||||
│ └── cleanCartsJob.js # Cron: limpiar carritos expirados
|
||||
│
|
||||
├── migrations/
|
||||
│ ├── 001_create_users.sql
|
||||
│ ├── 002_create_products.sql
|
||||
│ ├── 003_create_channels.sql
|
||||
│ ├── 004_create_inventory.sql
|
||||
│ ├── 005_create_orders.sql
|
||||
│ ├── 006_create_shipments.sql
|
||||
│ └── ...
|
||||
│
|
||||
├── public/
|
||||
│ └── uploads/
|
||||
│ └── products/ # Imágenes de productos
|
||||
│
|
||||
└── tests/
|
||||
├── auth.test.js
|
||||
├── products.test.js
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 FRONTEND - TIENDA (Clientes)
|
||||
|
||||
### Tecnologías sugeridas
|
||||
- **Framework:** React + Vite (o Next.js para SEO)
|
||||
- **Estado:** Redux Toolkit / Zustand
|
||||
- **UI:** TailwindCSS + Headless UI
|
||||
- **HTTP:** Axios
|
||||
- **Routing:** React Router
|
||||
|
||||
### Páginas principales
|
||||
```
|
||||
frontend/
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── layout/
|
||||
│ │ │ ├── Header.jsx
|
||||
│ │ │ ├── Footer.jsx
|
||||
│ │ │ └── Navbar.jsx
|
||||
│ │ ├── product/
|
||||
│ │ │ ├── ProductCard.jsx
|
||||
│ │ │ ├── ProductDetail.jsx
|
||||
│ │ │ └── ProductGallery.jsx
|
||||
│ │ ├── cart/
|
||||
│ │ │ ├── CartDrawer.jsx
|
||||
│ │ │ ├── CartItem.jsx
|
||||
│ │ │ └── CartSummary.jsx
|
||||
│ │ └── checkout/
|
||||
│ │ ├── CheckoutForm.jsx
|
||||
│ │ ├── AddressForm.jsx
|
||||
│ │ └── PaymentForm.jsx
|
||||
│ │
|
||||
│ ├── pages/
|
||||
│ │ ├── Home.jsx # Página principal
|
||||
│ │ ├── Products.jsx # Catálogo con filtros
|
||||
│ │ ├── ProductDetail.jsx # Detalle de producto
|
||||
│ │ ├── Cart.jsx # Carrito completo
|
||||
│ │ ├── Checkout.jsx # Proceso de compra
|
||||
│ │ ├── OrderConfirmation.jsx # Confirmación de orden
|
||||
│ │ ├── TrackOrder.jsx # Seguimiento de pedido
|
||||
│ │ ├── Profile.jsx # Perfil del usuario
|
||||
│ │ ├── Orders.jsx # Historial de órdenes
|
||||
│ │ └── Login.jsx # Login/Registro
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ ├── api.js # Configuración Axios
|
||||
│ │ ├── productService.js
|
||||
│ │ ├── cartService.js
|
||||
│ │ ├── orderService.js
|
||||
│ │ └── authService.js
|
||||
│ │
|
||||
│ ├── store/ # Redux/Zustand
|
||||
│ │ ├── cartSlice.js
|
||||
│ │ ├── authSlice.js
|
||||
│ │ └── store.js
|
||||
│ │
|
||||
│ ├── utils/
|
||||
│ │ ├── formatters.js
|
||||
│ │ └── validators.js
|
||||
│ │
|
||||
│ ├── App.jsx
|
||||
│ └── main.jsx
|
||||
│
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ ADMINISTRACIÓN - Panel Admin
|
||||
|
||||
### Tecnologías sugeridas
|
||||
- **Framework:** React + Vite
|
||||
- **UI Admin:** React Admin / Ant Design / Material UI
|
||||
- **Gráficos:** Chart.js / Recharts
|
||||
- **Tablas:** TanStack Table
|
||||
|
||||
### Módulos principales
|
||||
```
|
||||
administracion/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── layout/
|
||||
│ │ │ ├── AdminLayout.jsx
|
||||
│ │ │ ├── Sidebar.jsx
|
||||
│ │ │ └── TopBar.jsx
|
||||
│ │ ├── dashboard/
|
||||
│ │ │ ├── StatsCard.jsx
|
||||
│ │ │ ├── SalesChart.jsx
|
||||
│ │ │ └── RecentOrders.jsx
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── pages/
|
||||
│ │ ├── Dashboard.jsx # Estadísticas generales
|
||||
│ │ │
|
||||
│ │ ├── products/
|
||||
│ │ │ ├── ProductList.jsx # Listado de productos
|
||||
│ │ │ ├── ProductForm.jsx # Crear/Editar producto
|
||||
│ │ │ └── ProductStock.jsx # Control de inventario
|
||||
│ │ │
|
||||
│ │ ├── categories/
|
||||
│ │ │ ├── CategoryList.jsx
|
||||
│ │ │ └── CategoryForm.jsx
|
||||
│ │ │
|
||||
│ │ ├── orders/
|
||||
│ │ │ ├── OrderList.jsx # Todas las órdenes
|
||||
│ │ │ ├── OrderDetail.jsx # Detalle de orden
|
||||
│ │ │ └── OrderStatus.jsx # Cambiar estados
|
||||
│ │ │
|
||||
│ │ ├── shipments/
|
||||
│ │ │ ├── ShipmentList.jsx # Panel de despachos
|
||||
│ │ │ ├── ShipmentMap.jsx # Mapa de repartos
|
||||
│ │ │ └── ShipmentForm.jsx # Asignar repartidor
|
||||
│ │ │
|
||||
│ │ ├── channels/
|
||||
│ │ │ ├── ChannelList.jsx # Canales activos
|
||||
│ │ │ ├── ChannelSync.jsx # Sincronización manual
|
||||
│ │ │ └── SyncLogs.jsx # Historial de sync
|
||||
│ │ │
|
||||
│ │ ├── inventory/
|
||||
│ │ │ ├── StockList.jsx # Stock por producto
|
||||
│ │ │ ├── StockMovements.jsx # Historial de movimientos
|
||||
│ │ │ └── StockAdjust.jsx # Ajuste manual
|
||||
│ │ │
|
||||
│ │ ├── settings/
|
||||
│ │ │ ├── GeneralSettings.jsx
|
||||
│ │ │ ├── ChannelSettings.jsx # Config APIs
|
||||
│ │ │ ├── ShippingSettings.jsx
|
||||
│ │ │ └── PaymentSettings.jsx
|
||||
│ │ │
|
||||
│ │ └── reports/
|
||||
│ │ ├── SalesReport.jsx
|
||||
│ │ ├── StockReport.jsx
|
||||
│ │ └── ShipmentReport.jsx
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ └── (igual que frontend)
|
||||
│ │
|
||||
│ └── App.jsx
|
||||
│
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 INTEGRACIONES - APIs Externas
|
||||
|
||||
### 1. MercadoLibre API
|
||||
**Documentación:** https://developers.mercadolibre.cl
|
||||
|
||||
**Flujo de integración:**
|
||||
1. Autenticación OAuth 2.0
|
||||
2. Publicar producto con precio ajustado (+10%)
|
||||
3. Webhook para notificaciones de ventas
|
||||
4. Sincronización de stock automática
|
||||
5. Actualización de precios
|
||||
|
||||
**Endpoints clave:**
|
||||
- `POST /items` - Publicar producto
|
||||
- `PUT /items/:id` - Actualizar producto
|
||||
- `PUT /items/:id/stock` - Actualizar stock
|
||||
- `GET /orders/search` - Buscar órdenes
|
||||
|
||||
### 2. Instagram Shopping (Facebook Graph API)
|
||||
**Documentación:** https://developers.facebook.com/docs/commerce-platform
|
||||
|
||||
**Flujo de integración:**
|
||||
1. Crear cuenta empresarial de Instagram
|
||||
2. Vincular catálogo de productos
|
||||
3. Sincronizar productos como publicaciones comprables
|
||||
4. Webhook para interacciones
|
||||
|
||||
**Endpoints clave:**
|
||||
- `POST /catalog` - Crear catálogo
|
||||
- `POST /products` - Agregar productos
|
||||
- `PATCH /products/:id` - Actualizar producto
|
||||
|
||||
### 3. BlueExpress API
|
||||
**Documentación:** Contacto directo con BlueExpress
|
||||
|
||||
**Flujo de integración:**
|
||||
1. Credenciales API (solicitar a BlueExpress)
|
||||
2. Cotizar envío según destino y peso
|
||||
3. Generar orden de envío
|
||||
4. Obtener tracking number
|
||||
5. Webhook para estados de envío
|
||||
|
||||
**Funciones:**
|
||||
- Cotización automática en checkout
|
||||
- Generación de etiquetas
|
||||
- Tracking en tiempo real
|
||||
|
||||
### 4. Pasarela de Pagos - Webpay Plus (Transbank)
|
||||
**Documentación:** https://www.transbankdevelopers.cl
|
||||
|
||||
**Flujo de integración:**
|
||||
1. Obtener credenciales (ambiente de prueba/producción)
|
||||
2. Crear transacción en checkout
|
||||
3. Redireccionar a Webpay
|
||||
4. Confirmar transacción con callback
|
||||
5. Guardar comprobante
|
||||
|
||||
**Alternativas:**
|
||||
- Flow (más simple)
|
||||
- Mercado Pago (ya integrado si usas ML)
|
||||
- Khipu (transferencias)
|
||||
|
||||
---
|
||||
|
||||
## 📋 VARIABLES DE ENTORNO (.env)
|
||||
|
||||
```bash
|
||||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# Database
|
||||
DBTYPE=postgres
|
||||
DBHOST=localhost
|
||||
DBPORT=5432
|
||||
DBNAME=ofertaweb
|
||||
DBUSER=postgres
|
||||
DBPASS=tu_password
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=tu_secreto_super_seguro
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# URLs
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
ADMIN_URL=http://localhost:5174
|
||||
API_URL=http://localhost:3000
|
||||
|
||||
# MercadoLibre
|
||||
ML_CLIENT_ID=tu_client_id
|
||||
ML_CLIENT_SECRET=tu_client_secret
|
||||
ML_REDIRECT_URI=http://localhost:3000/api/channels/mercadolibre/callback
|
||||
ML_PRICE_FACTOR=1.10
|
||||
|
||||
# Instagram/Facebook
|
||||
FB_APP_ID=tu_app_id
|
||||
FB_APP_SECRET=tu_app_secret
|
||||
FB_ACCESS_TOKEN=tu_access_token
|
||||
|
||||
# BlueExpress
|
||||
BLUEX_API_KEY=tu_api_key
|
||||
BLUEX_API_SECRET=tu_api_secret
|
||||
BLUEX_ORIGIN_COMMUNE=santiago
|
||||
|
||||
# Transbank/Webpay
|
||||
TRANSBANK_COMMERCE_CODE=tu_codigo_comercio
|
||||
TRANSBANK_API_KEY=tu_api_key
|
||||
TRANSBANK_ENVIRONMENT=integration
|
||||
|
||||
# Email (Nodemailer)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=tu_email@gmail.com
|
||||
SMTP_PASS=tu_password
|
||||
|
||||
# Uploads
|
||||
MAX_FILE_SIZE=5242880
|
||||
ALLOWED_IMAGE_TYPES=image/jpeg,image/png,image/webp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚦 HOJA DE RUTA - FASES DE DESARROLLO
|
||||
|
||||
### **FASE 1: Fundamentos del Backend** (Semana 1-2)
|
||||
- [x] Configurar estructura de carpetas
|
||||
- [ ] Instalar dependencias necesarias
|
||||
- [ ] Crear esquema de base de datos
|
||||
- [ ] Ejecutar migraciones
|
||||
- [ ] Implementar modelos básicos
|
||||
- [ ] Sistema de autenticación (JWT)
|
||||
- [ ] Middleware de autenticación y roles
|
||||
|
||||
### **FASE 2: Módulo de Productos** (Semana 2-3)
|
||||
- [ ] CRUD de categorías
|
||||
- [ ] CRUD de productos
|
||||
- [ ] Sistema de imágenes múltiples
|
||||
- [ ] Validaciones
|
||||
- [ ] Filtros y búsqueda
|
||||
- [ ] Paginación
|
||||
|
||||
### **FASE 3: Sistema de Stock Multi-Canal** (Semana 3-4)
|
||||
- [ ] Modelo de inventario unificado
|
||||
- [ ] Servicio de sincronización de stock
|
||||
- [ ] Tabla de canales
|
||||
- [ ] Relación producto-canal
|
||||
- [ ] Reglas de precios por canal
|
||||
- [ ] Logs de sincronización
|
||||
|
||||
### **FASE 4: Carrito y Órdenes** (Semana 4-5)
|
||||
- [ ] Carrito de compras (usuarios + invitados)
|
||||
- [ ] Creación de órdenes
|
||||
- [ ] Cálculo de totales
|
||||
- [ ] Reserva temporal de stock
|
||||
- [ ] Estados de órdenes
|
||||
- [ ] Historial de órdenes del usuario
|
||||
|
||||
### **FASE 5: Integración MercadoLibre** (Semana 5-6)
|
||||
- [ ] OAuth 2.0 authentication
|
||||
- [ ] Publicar producto en ML
|
||||
- [ ] Actualizar precio (+10%)
|
||||
- [ ] Sincronizar stock
|
||||
- [ ] Webhook de ventas ML
|
||||
- [ ] Importar órdenes de ML
|
||||
|
||||
### **FASE 6: Integración Instagram Shopping** (Semana 6-7)
|
||||
- [ ] Configurar Facebook Business
|
||||
- [ ] Crear catálogo de productos
|
||||
- [ ] Sincronizar productos
|
||||
- [ ] Actualizar stock en tiempo real
|
||||
- [ ] Webhook de interacciones
|
||||
|
||||
### **FASE 7: Sistema de Envíos (BlueExpress)** (Semana 7-8)
|
||||
- [ ] Integración API BlueExpress
|
||||
- [ ] Cotización automática en checkout
|
||||
- [ ] Generación de orden de envío
|
||||
- [ ] Obtener tracking number
|
||||
- [ ] Actualización de estados
|
||||
- [ ] Webhook de tracking
|
||||
|
||||
### **FASE 8: Pasarela de Pagos** (Semana 8-9)
|
||||
- [ ] Integración Webpay Plus
|
||||
- [ ] Crear transacción
|
||||
- [ ] Callback de confirmación
|
||||
- [ ] Validar pago
|
||||
- [ ] Actualizar estado de orden
|
||||
- [ ] Envío de comprobante por email
|
||||
|
||||
### **FASE 9: Sistema de Despacho** (Semana 9-10)
|
||||
- [ ] Modelo de shipments
|
||||
- [ ] Estados de despacho
|
||||
- [ ] Asignación de repartidor
|
||||
- [ ] Tracking interno
|
||||
- [ ] Comprobante de entrega
|
||||
- [ ] Notificaciones al cliente
|
||||
|
||||
### **FASE 10: Frontend Tienda** (Semana 10-12)
|
||||
- [ ] Setup del proyecto React
|
||||
- [ ] Componentes de layout
|
||||
- [ ] Página de inicio
|
||||
- [ ] Catálogo de productos
|
||||
- [ ] Detalle de producto
|
||||
- [ ] Carrito de compras
|
||||
- [ ] Proceso de checkout
|
||||
- [ ] Integración con pasarela
|
||||
- [ ] Seguimiento de pedido
|
||||
- [ ] Perfil de usuario
|
||||
|
||||
### **FASE 11: Panel de Administración** (Semana 12-14)
|
||||
- [ ] Setup del proyecto admin
|
||||
- [ ] Dashboard con estadísticas
|
||||
- [ ] CRUD de productos
|
||||
- [ ] Gestión de categorías
|
||||
- [ ] Control de inventario
|
||||
- [ ] Gestión de órdenes
|
||||
- [ ] Panel de despachos
|
||||
- [ ] Sincronización de canales
|
||||
- [ ] Configuración del sistema
|
||||
- [ ] Reportes
|
||||
|
||||
### **FASE 12: Testing y Optimización** (Semana 14-15)
|
||||
- [ ] Tests unitarios (backend)
|
||||
- [ ] Tests de integración
|
||||
- [ ] Optimización de queries
|
||||
- [ ] Manejo de errores
|
||||
- [ ] Logs del sistema
|
||||
- [ ] Seguridad (sanitización, rate limiting)
|
||||
- [ ] Documentación de API (Swagger)
|
||||
|
||||
### **FASE 13: Deploy y Producción** (Semana 15-16)
|
||||
- [ ] Configurar servidor (VPS/AWS/Heroku)
|
||||
- [ ] Deploy del backend
|
||||
- [ ] Deploy del frontend
|
||||
- [ ] Deploy del admin
|
||||
- [ ] Configurar dominio y SSL
|
||||
- [ ] Credenciales de producción (ML, IG, Transbank)
|
||||
- [ ] Backups automáticos
|
||||
- [ ] Monitoreo
|
||||
|
||||
---
|
||||
|
||||
## 📦 DEPENDENCIAS PRINCIPALES
|
||||
|
||||
### Backend (package.json)
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"pg": "^8.16.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"cors": "^2.8.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"joi": "^17.11.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sharp": "^0.33.1",
|
||||
"axios": "^1.6.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.7",
|
||||
"mercadolibre": "^0.0.13",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^6.3.3",
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend/Admin (package.json)
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"axios": "^1.6.2",
|
||||
"zustand": "^4.4.7",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"chart.js": "^4.4.1",
|
||||
"react-chartjs-2": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.0.8",
|
||||
"@vitejs/plugin-react": "^4.2.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 MÉTRICAS DE ÉXITO
|
||||
|
||||
### KPIs Técnicos
|
||||
- Tiempo de respuesta API < 200ms
|
||||
- Disponibilidad del sistema > 99%
|
||||
- Sincronización de stock en < 5 minutos
|
||||
- Tasa de error en integraciones < 1%
|
||||
|
||||
### KPIs de Negocio
|
||||
- Conversión de carrito a orden > 10%
|
||||
- Tiempo promedio de despacho < 48h
|
||||
- Satisfacción del cliente > 4.5/5
|
||||
- Ventas multi-canal > 30% del total
|
||||
|
||||
---
|
||||
|
||||
## 📞 PREGUNTAS PENDIENTES
|
||||
|
||||
1. ✅ **Pasarela de pagos:** ¿Webpay Plus, Flow, Mercado Pago o Khipu?
|
||||
2. ✅ **Cuenta MercadoLibre:** ¿Ya tienes cuenta de desarrollador?
|
||||
3. ✅ **Instagram Business:** ¿Cuenta empresarial configurada?
|
||||
4. ✅ **BlueExpress:** ¿Contrato existente o explorar Chilexpress/Starken?
|
||||
5. ❓ **Hosting:** ¿Dónde desplegar? (AWS, Heroku, VPS propio, Vercel)
|
||||
6. ❓ **Email:** ¿Qué servicio de email? (Gmail, SendGrid, AWS SES)
|
||||
|
||||
---
|
||||
|
||||
## 📝 NOTAS ADICIONALES
|
||||
|
||||
- El sistema debe ser **escalable** para agregar más canales en el futuro
|
||||
- Considerar **cache** (Redis) para mejorar performance
|
||||
- Implementar **rate limiting** en APIs
|
||||
- Sistema de **logs** robusto para debugging
|
||||
- **Webhooks** para mantener sincronización en tiempo real
|
||||
- **Cron jobs** como respaldo de sincronización
|
||||
|
||||
---
|
||||
|
||||
**Última actualización:** Diciembre 5, 2025
|
||||
**Estado:** Planificación inicial
|
||||
1
administracion/.env
Normal file
1
administracion/.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
1
administracion/.env.example
Normal file
1
administracion/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
24
administracion/.gitignore
vendored
Normal file
24
administracion/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
administracion/README.md
Normal file
16
administracion/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
29
administracion/eslint.config.js
Normal file
29
administracion/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
administracion/index.html
Normal file
13
administracion/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>administracion</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4186
administracion/package-lock.json
generated
Normal file
4186
administracion/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
administracion/package.json
Normal file
37
administracion/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "administracion",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"jsbarcode": "^3.12.1",
|
||||
"lucide-react": "^0.556.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
6
administracion/postcss.config.js
Normal file
6
administracion/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
administracion/public/vite.svg
Normal file
1
administracion/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
administracion/src/App.css
Normal file
42
administracion/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
66
administracion/src/App.jsx
Normal file
66
administracion/src/App.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import AdminLayout from './components/AdminLayout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Products from './pages/Products';
|
||||
import Categories from './pages/Categories';
|
||||
import Inventory from './pages/Inventory';
|
||||
import Orders from './pages/Orders';
|
||||
import Shipments from './pages/Shipments';
|
||||
import Channels from './pages/Channels';
|
||||
import Settings from './pages/Settings';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="categories" element={<Categories />} />
|
||||
<Route path="inventory" element={<Inventory />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="shipments" element={<Shipments />} />
|
||||
<Route path="channels" element={<Channels />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
administracion/src/assets/react.svg
Normal file
1
administracion/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
113
administracion/src/components/AdminLayout.jsx
Normal file
113
administracion/src/components/AdminLayout.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Package, FolderTree, Warehouse,
|
||||
ShoppingCart, TruckIcon, Share2, Settings, LogOut, Menu, X
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function AdminLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' },
|
||||
{ icon: Package, label: 'Productos', path: '/products' },
|
||||
{ icon: FolderTree, label: 'Categorías', path: '/categories' },
|
||||
{ icon: Warehouse, label: 'Inventario', path: '/inventory' },
|
||||
{ icon: ShoppingCart, label: 'Órdenes', path: '/orders' },
|
||||
{ icon: TruckIcon, label: 'Envíos', path: '/shipments' },
|
||||
{ icon: Share2, label: 'Canales', path: '/channels' },
|
||||
{ icon: Settings, label: 'Configuración', path: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<aside className={`bg-gray-900 text-white transition-all duration-300 ${sidebarOpen ? 'w-64' : 'w-20'}`}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
{sidebarOpen && <h1 className="text-xl font-bold">OfertaWeb</h1>}
|
||||
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2 hover:bg-gray-800 rounded">
|
||||
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="mt-4">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-4 py-3 hover:bg-gray-800 transition-colors ${
|
||||
isActive ? 'bg-gray-800 border-l-4 border-blue-500' : ''
|
||||
}`}
|
||||
title={!sidebarOpen ? item.label : ''}
|
||||
>
|
||||
<Icon size={20} />
|
||||
{sidebarOpen && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center font-bold">
|
||||
{user?.nombre?.charAt(0)}
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{user?.nombre}</p>
|
||||
<p className="text-xs text-gray-400">{user?.role}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 hover:bg-gray-800 rounded"
|
||||
title="Cerrar sesión"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">
|
||||
{menuItems.find(item => item.path === location.pathname)?.label || 'Panel de Administración'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{new Date().toLocaleDateString('es-CL', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
administracion/src/components/CategoryModal.jsx
Normal file
176
administracion/src/components/CategoryModal.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { X } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { categoryService } from '../services';
|
||||
|
||||
export default function CategoryModal({ category, categories, onClose }) {
|
||||
const { register, handleSubmit, formState: { errors }, reset } = useForm();
|
||||
const isEditing = !!category;
|
||||
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
reset(category);
|
||||
}
|
||||
}, [category, reset]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
try {
|
||||
if (isEditing) {
|
||||
await categoryService.update(category.id, data);
|
||||
toast.success('Categoría actualizada');
|
||||
} else {
|
||||
await categoryService.create(data);
|
||||
toast.success('Categoría creada');
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'Error al guardar categoría');
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrar categorías para no permitir seleccionar como padre a sí misma o sus hijos
|
||||
const availableParents = categories.filter(cat => {
|
||||
if (!isEditing) return true;
|
||||
return cat.id !== category.id;
|
||||
});
|
||||
|
||||
// Aplanar el árbol de categorías para el select
|
||||
const flattenCategories = (cats, level = 0) => {
|
||||
let result = [];
|
||||
cats.forEach(cat => {
|
||||
result.push({ ...cat, level });
|
||||
if (cat.children && cat.children.length > 0) {
|
||||
result = result.concat(flattenCategories(cat.children, level + 1));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const flatCategories = flattenCategories(availableParents);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-lg w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-2xl font-bold text-gray-800">
|
||||
{isEditing ? 'Editar Categoría' : 'Nueva Categoría'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
|
||||
{/* Nombre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('nombre', { required: 'Nombre es requerido' })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Ej: Electrónica"
|
||||
/>
|
||||
{errors.nombre && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.nombre.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Código */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Código para SKU
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('codigo', {
|
||||
maxLength: { value: 10, message: 'Máximo 10 caracteres' },
|
||||
pattern: { value: /^[A-Z0-9]*$/, message: 'Solo mayúsculas y números' }
|
||||
})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent uppercase"
|
||||
placeholder="ELEC"
|
||||
maxLength={10}
|
||||
/>
|
||||
{errors.codigo && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.codigo.message}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Se usará para generar SKUs automáticos (ej: ELEC-001). Se auto-genera si está vacío.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Descripción */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
{...register('descripcion')}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Descripción de la categoría"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Categoría Padre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Categoría Padre
|
||||
</label>
|
||||
<select
|
||||
{...register('parent_id')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Sin categoría padre (raíz)</option>
|
||||
{flatCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{'—'.repeat(cat.level)} {cat.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Estado */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('is_active')}
|
||||
defaultChecked={isEditing ? category.is_active : true}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-700">
|
||||
Categoría activa
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-4 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{isEditing ? 'Actualizar' : 'Crear Categoría'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
administracion/src/components/ImageUploader.jsx
Normal file
175
administracion/src/components/ImageUploader.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { Upload, X, Image as ImageIcon, Star } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const API_URL = 'http://localhost:3001/api';
|
||||
|
||||
export default function ImageUploader({ productId, images, onImagesChange }) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('alt_text', file.name);
|
||||
formData.append('is_primary', images.length === 0); // Primera imagen es principal
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_URL}/upload/product/${productId}`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onImagesChange([...images, response.data.image]);
|
||||
}
|
||||
|
||||
toast.success(`${files.length} imagen(es) subida(s)`);
|
||||
e.target.value = ''; // Reset input
|
||||
} catch (error) {
|
||||
console.error('Error subiendo imágenes:', error);
|
||||
toast.error('Error al subir imágenes');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (imageId) => {
|
||||
if (!confirm('¿Eliminar esta imagen?')) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`${API_URL}/upload/image/${imageId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
onImagesChange(images.filter(img => img.id !== imageId));
|
||||
toast.success('Imagen eliminada');
|
||||
} catch (error) {
|
||||
console.error('Error eliminando imagen:', error);
|
||||
toast.error('Error al eliminar imagen');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrimary = async (imageId) => {
|
||||
try {
|
||||
// Actualizar localmente
|
||||
const updatedImages = images.map(img => ({
|
||||
...img,
|
||||
is_primary: img.id === imageId
|
||||
}));
|
||||
onImagesChange(updatedImages);
|
||||
|
||||
// Aquí podrías hacer un PATCH al backend si implementas ese endpoint
|
||||
toast.success('Imagen principal actualizada');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
toast.error('Error al actualizar');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Imágenes del Producto
|
||||
</label>
|
||||
<label className="cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||
<Upload size={16} />
|
||||
{uploading ? 'Subiendo...' : 'Subir Imágenes'}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Grid de imágenes */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group aspect-square bg-gray-100 rounded-lg overflow-hidden border-2 border-gray-200 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={`http://localhost:3001${image.url}`}
|
||||
alt={image.alt_text}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Overlay con acciones */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-all flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetPrimary(image.id)}
|
||||
className={`p-2 rounded-full transition-all ${
|
||||
image.is_primary
|
||||
? 'bg-yellow-500 text-white'
|
||||
: 'bg-white text-gray-700 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={image.is_primary ? 'Imagen principal' : 'Marcar como principal'}
|
||||
>
|
||||
<Star size={16} fill={image.is_primary ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(image.id)}
|
||||
className="p-2 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 hover:bg-red-600 transition-all"
|
||||
title="Eliminar"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Badge de imagen principal */}
|
||||
{image.is_primary && (
|
||||
<div className="absolute top-2 left-2 bg-yellow-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
<Star size={12} fill="currentColor" />
|
||||
Principal
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Placeholder para subir */}
|
||||
{images.length === 0 && (
|
||||
<label className="cursor-pointer aspect-square bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center hover:border-blue-500 hover:bg-blue-50 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<ImageIcon size={32} className="text-gray-400 mb-2" />
|
||||
<span className="text-sm text-gray-500">Subir imágenes</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Puedes subir múltiples imágenes. La primera será la principal. Máximo 5MB por imagen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
administracion/src/components/PrintLabel.jsx
Normal file
115
administracion/src/components/PrintLabel.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Printer } from 'lucide-react';
|
||||
import JsBarcode from 'jsbarcode';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function PrintLabel({ product, onClose }) {
|
||||
const barcodeRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (barcodeRef.current && product.sku) {
|
||||
try {
|
||||
JsBarcode(barcodeRef.current, product.sku, {
|
||||
format: 'CODE128',
|
||||
width: 2,
|
||||
height: 60,
|
||||
displayValue: true,
|
||||
fontSize: 14,
|
||||
margin: 5
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error generando código de barras:', err);
|
||||
}
|
||||
}
|
||||
}, [product.sku]);
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Vista para pantalla */}
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 print:hidden">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Vista previa de etiqueta</h2>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-300 p-4 mb-4 bg-white">
|
||||
<LabelContent product={product} barcodeRef={barcodeRef} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Printer size={20} />
|
||||
Imprimir
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Selecciona tu impresora de etiquetas en el diálogo de impresión
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vista para impresión */}
|
||||
<div className="hidden print:block">
|
||||
<LabelContent product={product} barcodeRef={barcodeRef} />
|
||||
</div>
|
||||
|
||||
{/* Estilos de impresión */}
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: 10cm 5cm;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.print\\:block, .print\\:block * {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.print\\:block {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 10cm;
|
||||
height: 5cm;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelContent({ product, barcodeRef }) {
|
||||
return (
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="font-bold text-lg leading-tight line-clamp-2">
|
||||
{product.nombre}
|
||||
</h3>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<svg ref={barcodeRef}></svg>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="font-mono text-xs">{product.sku}</span>
|
||||
<span className="font-bold text-xl">
|
||||
${parseInt(product.precio_base).toLocaleString('es-CL')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
310
administracion/src/components/ProductModal.jsx
Normal file
310
administracion/src/components/ProductModal.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { X, RefreshCw } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { productService } from '../services';
|
||||
import ImageUploader from './ImageUploader';
|
||||
|
||||
export default function ProductModal({ product, categories, onClose }) {
|
||||
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useForm();
|
||||
const isEditing = !!product;
|
||||
const [skuCounter, setSkuCounter] = useState(1);
|
||||
const [images, setImages] = useState([]);
|
||||
|
||||
const categoryId = watch('category_id');
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
reset(product);
|
||||
setImages(product.images || []);
|
||||
}
|
||||
}, [product, reset]);
|
||||
|
||||
// Auto-generar SKU cuando cambia la categoría
|
||||
useEffect(() => {
|
||||
if (!isEditing && categoryId) {
|
||||
generateSKU(categoryId);
|
||||
}
|
||||
}, [categoryId, isEditing]);
|
||||
|
||||
const generateSKU = (catId) => {
|
||||
const category = categories?.find(c => c.id === parseInt(catId));
|
||||
if (category) {
|
||||
const prefix = category.codigo || category.nombre.substring(0, 4).toUpperCase();
|
||||
const newSKU = `${prefix}-${String(skuCounter).padStart(3, '0')}`;
|
||||
setValue('sku', newSKU);
|
||||
setSkuCounter(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateSKU = () => {
|
||||
const catId = watch('category_id');
|
||||
if (catId) {
|
||||
generateSKU(catId);
|
||||
} else {
|
||||
const newSKU = `PROD-${String(skuCounter).padStart(3, '0')}`;
|
||||
setValue('sku', newSKU);
|
||||
setSkuCounter(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
try {
|
||||
if (isEditing) {
|
||||
await productService.update(product.id, data);
|
||||
toast.success('Producto actualizado');
|
||||
} else {
|
||||
await productService.create(data);
|
||||
toast.success('Producto creado');
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'Error al guardar producto');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-2xl font-bold text-gray-800">
|
||||
{isEditing ? 'Editar Producto' : 'Nuevo Producto'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Categoría - Movido primero */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Categoría
|
||||
</label>
|
||||
<select
|
||||
{...register('category_id')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Sin categoría</option>
|
||||
{categories && categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* SKU con botón regenerar */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
SKU / Código de Barras <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
{...register('sku', { required: 'SKU es requerido' })}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Se genera automáticamente"
|
||||
disabled={isEditing}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegenerateSKU}
|
||||
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
title="Generar nuevo SKU"
|
||||
>
|
||||
<RefreshCw size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.sku && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.sku.message}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{!isEditing ? 'Se genera automáticamente o puedes ingresar código de barras' : 'El SKU no puede modificarse'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nombre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre del Producto <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('nombre', { required: 'Nombre es requerido' })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Nombre del producto"
|
||||
/>
|
||||
{errors.nombre && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.nombre.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Descripción */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
{...register('descripcion')}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Descripción del producto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Precio */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Precio Base <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('precio_base', {
|
||||
required: 'Precio es requerido',
|
||||
min: { value: 0, message: 'Precio debe ser mayor a 0' }
|
||||
})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="19990"
|
||||
/>
|
||||
{errors.precio_base && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.precio_base.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peso */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Peso (gramos)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('peso_gramos')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dimensiones */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Alto (cm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('alto_cm')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ancho (cm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('ancho_cm')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Largo (cm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('largo_cm')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Stock Inicial
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('stock_inicial', { min: 0 })}
|
||||
defaultValue={0}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Estado */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('is_active')}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-700">
|
||||
Producto activo
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Galería de imágenes - Solo si el producto ya existe */}
|
||||
{isEditing && product?.id && (
|
||||
<ImageUploader
|
||||
productId={product.id}
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mensaje para nuevos productos */}
|
||||
{!isEditing && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
💡 Podrás subir imágenes después de crear el producto
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-4 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{isEditing ? 'Actualizar' : 'Crear Producto'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
administracion/src/components/ProtectedRoute.jsx
Normal file
12
administracion/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
150
administracion/src/components/StockAdjustModal.jsx
Normal file
150
administracion/src/components/StockAdjustModal.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { X } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { inventoryService } from '../services';
|
||||
|
||||
export default function StockAdjustModal({ product, onClose }) {
|
||||
const { register, handleSubmit, formState: { errors }, watch } = useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tipoMovimiento = watch('tipo_movimiento', 'entrada');
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await inventoryService.adjustStock({
|
||||
product_id: product.id,
|
||||
tipo_movimiento: data.tipo_movimiento,
|
||||
cantidad: parseInt(data.cantidad),
|
||||
motivo: data.motivo
|
||||
});
|
||||
|
||||
toast.success('Stock ajustado correctamente');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error ajustando stock:', error);
|
||||
toast.error(error.response?.data?.error || 'Error al ajustar stock');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-lg w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">Ajustar Stock</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">{product.nombre}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stock actual */}
|
||||
<div className="px-6 py-4 bg-gray-50 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Stock Actual:</span>
|
||||
<span className="text-2xl font-bold text-gray-800">{product.stock_actual || 0}</span>
|
||||
</div>
|
||||
{product.stock_reservado > 0 && (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-sm text-gray-600">Stock Reservado:</span>
|
||||
<span className="text-sm font-medium text-yellow-600">{product.stock_reservado}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
|
||||
{/* Tipo de movimiento */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de Movimiento <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
{...register('tipo_movimiento', { required: 'Tipo de movimiento es requerido' })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="entrada">Entrada (+)</option>
|
||||
<option value="salida">Salida (-)</option>
|
||||
<option value="ajuste">Ajuste</option>
|
||||
</select>
|
||||
{errors.tipo_movimiento && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.tipo_movimiento.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cantidad */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Cantidad <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('cantidad', {
|
||||
required: 'Cantidad es requerida',
|
||||
min: { value: 1, message: 'Cantidad debe ser mayor a 0' }
|
||||
})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="0"
|
||||
/>
|
||||
{errors.cantidad && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.cantidad.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Motivo */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Motivo <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
{...register('motivo', { required: 'Motivo es requerido' })}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={
|
||||
tipoMovimiento === 'entrada'
|
||||
? 'Ej: Compra de proveedor, Devolución de cliente'
|
||||
: tipoMovimiento === 'salida'
|
||||
? 'Ej: Venta, Producto dañado, Muestra'
|
||||
: 'Ej: Corrección de inventario, Auditoría'
|
||||
}
|
||||
/>
|
||||
{errors.motivo && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.motivo.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-4 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Guardando...' : 'Ajustar Stock'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
administracion/src/index.css
Normal file
17
administracion/src/index.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
10
administracion/src/main.jsx
Normal file
10
administracion/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
144
administracion/src/pages/Categories.jsx
Normal file
144
administracion/src/pages/Categories.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Edit, Trash2, FolderTree } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { categoryService } from '../services';
|
||||
import CategoryModal from '../components/CategoryModal';
|
||||
|
||||
export default function Categories() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await categoryService.getTree();
|
||||
setCategories(response.data || []);
|
||||
} catch (err) {
|
||||
console.error('Error cargando categorías:', err);
|
||||
toast.error('Error cargando categorías');
|
||||
setCategories([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (category) => {
|
||||
setSelectedCategory(category);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (category) => {
|
||||
if (!confirm(`¿Eliminar categoría "${category.nombre}"?`)) return;
|
||||
|
||||
try {
|
||||
await categoryService.delete(category.id);
|
||||
toast.success('Categoría eliminada');
|
||||
loadCategories();
|
||||
} catch (err) {
|
||||
console.error('Error eliminando categoría:', err);
|
||||
toast.error('Error al eliminar categoría');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOpen(false);
|
||||
setSelectedCategory(null);
|
||||
loadCategories();
|
||||
};
|
||||
|
||||
const renderCategory = (category, level = 0) => (
|
||||
<div key={category.id}>
|
||||
<div className="flex items-center justify-between p-4 bg-white border-b hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3" style={{ paddingLeft: `${level * 2}rem` }}>
|
||||
{level > 0 && <span className="text-gray-400">└─</span>}
|
||||
<FolderTree size={20} className="text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{category.nombre}</p>
|
||||
{category.descripcion && (
|
||||
<p className="text-sm text-gray-500">{category.descripcion}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(category)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{category.children && category.children.map(child => renderCategory(child, level + 1))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Categorías</h1>
|
||||
<p className="text-gray-600 mt-1">Organiza tus productos en categorías</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Nueva Categoría
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lista de categorías en árbol */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{!categories || categories.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FolderTree size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-600">No hay categorías creadas</p>
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="mt-4 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Crear primera categoría
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{categories.map(category => renderCategory(category))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && (
|
||||
<CategoryModal
|
||||
category={selectedCategory}
|
||||
categories={categories}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
administracion/src/pages/Channels.jsx
Normal file
110
administracion/src/pages/Channels.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Share2, Instagram, ShoppingBag, TrendingUp, Package } from 'lucide-react';
|
||||
|
||||
export default function Channels() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Canales de Venta</h1>
|
||||
<p className="text-gray-600 mt-1">Integración con MercadoLibre e Instagram Shopping</p>
|
||||
</div>
|
||||
|
||||
{/* Placeholder */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* MercadoLibre */}
|
||||
<div className="bg-white rounded-lg shadow p-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-yellow-100 rounded-full mb-4">
|
||||
<ShoppingBag size={32} className="text-yellow-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-2">MercadoLibre</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Sincroniza productos con markup del 10%
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 mt-6">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">Estado</span>
|
||||
<span className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-xs font-medium">
|
||||
No configurado
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">Productos sincronizados</span>
|
||||
<span className="font-semibold">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="mt-6 w-full px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors">
|
||||
Configurar Integración
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instagram Shopping */}
|
||||
<div className="bg-white rounded-lg shadow p-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-pink-100 rounded-full mb-4">
|
||||
<Instagram size={32} className="text-pink-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-2">Instagram Shopping</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Catálogo sincronizado con Facebook
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 mt-6">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">Estado</span>
|
||||
<span className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-xs font-medium">
|
||||
No configurado
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">Productos en catálogo</span>
|
||||
<span className="font-semibold">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="mt-6 w-full px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors">
|
||||
Configurar Integración
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="bg-white rounded-lg shadow p-8">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-6">Funcionalidades Multi-Canal</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Share2 size={24} className="text-blue-600 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-800 mb-1">Sincronización Automática</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Los productos se publican automáticamente en todos los canales
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<TrendingUp size={24} className="text-green-600 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-800 mb-1">Markup Inteligente</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Ajuste de precios por canal (MercadoLibre +10%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Package size={24} className="text-purple-600 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-800 mb-1">Stock Unificado</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Inventario centralizado para todos los canales
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
administracion/src/pages/Dashboard.jsx
Normal file
139
administracion/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Package, ShoppingCart, TruckIcon, AlertCircle, TrendingUp, DollarSign } from 'lucide-react';
|
||||
import { productService, inventoryService } from '../services';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState({
|
||||
totalProductos: 0,
|
||||
productosActivos: 0,
|
||||
ordenesHoy: 0,
|
||||
stockBajo: 0,
|
||||
ventasHoy: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const [products, lowStock] = await Promise.all([
|
||||
productService.getAll({ limit: 100 }),
|
||||
inventoryService.getLowStock()
|
||||
]);
|
||||
|
||||
setStats({
|
||||
totalProductos: products.pagination.totalItems,
|
||||
productosActivos: products.data.filter(p => p.is_active).length,
|
||||
ordenesHoy: 0, // TODO: implementar
|
||||
stockBajo: lowStock.products.length,
|
||||
ventasHoy: 0 // TODO: implementar
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error cargando estadísticas:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const StatCard = ({ icon: Icon, title, value, color, subtext }) => {
|
||||
const IconComponent = Icon;
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm font-medium">{title}</p>
|
||||
<p className="text-3xl font-bold text-gray-800 mt-2">{value}</p>
|
||||
{subtext && <p className="text-sm text-gray-500 mt-1">{subtext}</p>}
|
||||
</div>
|
||||
<div className={`p-4 rounded-full ${color}`}>
|
||||
<IconComponent size={28} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-gray-800">Dashboard</h1>
|
||||
<button
|
||||
onClick={loadStats}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Estadísticas principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
icon={Package}
|
||||
title="Total Productos"
|
||||
value={stats.totalProductos}
|
||||
color="bg-blue-500"
|
||||
subtext={`${stats.productosActivos} activos`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={ShoppingCart}
|
||||
title="Órdenes Hoy"
|
||||
value={stats.ordenesHoy}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={DollarSign}
|
||||
title="Ventas Hoy"
|
||||
value={`$${stats.ventasHoy.toLocaleString()}`}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertCircle}
|
||||
title="Stock Bajo"
|
||||
value={stats.stockBajo}
|
||||
color="bg-red-500"
|
||||
subtext="Requieren atención"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alertas */}
|
||||
{stats.stockBajo > 0 && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded">
|
||||
<div className="flex">
|
||||
<AlertCircle className="text-yellow-400 mr-3" size={24} />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-yellow-800">
|
||||
Atención: Productos con stock bajo
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Hay {stats.stockBajo} producto(s) que requieren reposición.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gráficos y tablas */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Ventas Recientes</h3>
|
||||
<p className="text-gray-500 text-center py-8">Sin órdenes recientes</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Productos Más Vendidos</h3>
|
||||
<p className="text-gray-500 text-center py-8">Sin datos disponibles</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
administracion/src/pages/Inventory.jsx
Normal file
202
administracion/src/pages/Inventory.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Package, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { productService } from '../services';
|
||||
import StockAdjustModal from '../components/StockAdjustModal';
|
||||
|
||||
export default function Inventory() {
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||
const [filter, setFilter] = useState('all'); // all, low, out
|
||||
|
||||
useEffect(() => {
|
||||
loadInventory();
|
||||
}, []);
|
||||
|
||||
const loadInventory = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await productService.getAll({ limit: 100 });
|
||||
setInventory(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error cargando inventario:', err);
|
||||
toast.error('Error cargando inventario');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdjust = (product) => {
|
||||
setSelectedProduct(product);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOpen(false);
|
||||
setSelectedProduct(null);
|
||||
loadInventory();
|
||||
};
|
||||
|
||||
const getStockStatus = (stock, minStock = 10) => {
|
||||
if (stock === 0) return { color: 'text-red-600 bg-red-50', icon: AlertTriangle, text: 'Agotado' };
|
||||
if (stock <= minStock) return { color: 'text-yellow-600 bg-yellow-50', icon: TrendingDown, text: 'Stock Bajo' };
|
||||
return { color: 'text-green-600 bg-green-50', icon: TrendingUp, text: 'Normal' };
|
||||
};
|
||||
|
||||
const filteredInventory = inventory.filter(item => {
|
||||
if (filter === 'low') return item.stock_actual > 0 && item.stock_actual <= (item.stock_minimo || 10);
|
||||
if (filter === 'out') return item.stock_actual === 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Inventario</h1>
|
||||
<p className="text-gray-600 mt-1">Control de stock y movimientos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 border'
|
||||
}`}
|
||||
>
|
||||
Todos ({inventory.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('low')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === 'low'
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 border'
|
||||
}`}
|
||||
>
|
||||
Stock Bajo ({inventory.filter(i => i.stock_actual > 0 && i.stock_actual <= 10).length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('out')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === 'out'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 border'
|
||||
}`}
|
||||
>
|
||||
Agotados ({inventory.filter(i => i.stock_actual === 0).length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabla de inventario */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Producto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
SKU
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stock Actual
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stock Reservado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredInventory.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-6 py-12 text-center text-gray-500">
|
||||
No hay productos que mostrar
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredInventory.map((product) => {
|
||||
const status = getStockStatus(product.stock_actual, product.stock_minimo);
|
||||
const StatusIcon = status.icon;
|
||||
return (
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{product.imagen_principal ? (
|
||||
<img
|
||||
src={`http://localhost:3001${product.imagen_principal}`}
|
||||
alt={product.nombre}
|
||||
className="h-10 w-10 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 bg-gray-200 rounded flex items-center justify-center">
|
||||
<Package size={20} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{product.nombre}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-gray-500">{product.sku}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-semibold text-gray-900">{product.stock_actual || 0}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-gray-500">{product.stock_reservado || 0}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.color}`}>
|
||||
<StatusIcon size={14} />
|
||||
{status.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button
|
||||
onClick={() => handleAdjust(product)}
|
||||
className="text-blue-600 hover:text-blue-900 font-medium"
|
||||
>
|
||||
Ajustar Stock
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && selectedProduct && (
|
||||
<StockAdjustModal
|
||||
product={selectedProduct}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
administracion/src/pages/Login.jsx
Normal file
96
administracion/src/pages/Login.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authService } from '../services';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuthStore();
|
||||
const { register, handleSubmit, formState: { errors } } = useForm();
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await authService.login(data.email, data.password);
|
||||
|
||||
console.log('Login response:', response);
|
||||
|
||||
if (response.user.role !== 'admin') {
|
||||
toast.error('No tienes permisos de administrador');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
login(response.user, response.token);
|
||||
toast.success('¡Bienvenido!');
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
toast.error(error.response?.data?.error || 'Error al iniciar sesión');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600">
|
||||
<div className="bg-white rounded-lg shadow-2xl p-8 w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-block p-3 bg-blue-100 rounded-full mb-4">
|
||||
<LogIn size={32} className="text-blue-600" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-800">OfertaWeb</h1>
|
||||
<p className="text-gray-600 mt-2">Panel de Administración</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email', { required: 'Email es requerido' })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="admin@ofertaweb.cl"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('password', { required: 'Contraseña es requerida' })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
<p>Usuario de prueba: <strong>admin@ofertaweb.cl</strong></p>
|
||||
<p>Contraseña: <strong>admin123</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
administracion/src/pages/Orders.jsx
Normal file
159
administracion/src/pages/Orders.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ShoppingCart, Eye, Package, Truck, CheckCircle, XCircle } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { orderService } from '../services';
|
||||
|
||||
export default function Orders() {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
loadOrders();
|
||||
}, []);
|
||||
|
||||
const loadOrders = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await orderService.getAll();
|
||||
setOrders(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error cargando órdenes:', err);
|
||||
toast.error('Error cargando órdenes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (orderId, newStatus) => {
|
||||
try {
|
||||
await orderService.updateStatus(orderId, newStatus);
|
||||
toast.success('Estado actualizado');
|
||||
loadOrders();
|
||||
} catch (err) {
|
||||
console.error('Error actualizando estado:', err);
|
||||
toast.error('Error al actualizar estado');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusInfo = (status) => {
|
||||
const statusMap = {
|
||||
pendiente: { color: 'bg-yellow-100 text-yellow-800', icon: ShoppingCart, text: 'Pendiente' },
|
||||
confirmada: { color: 'bg-blue-100 text-blue-800', icon: CheckCircle, text: 'Confirmada' },
|
||||
preparando: { color: 'bg-purple-100 text-purple-800', icon: Package, text: 'Preparando' },
|
||||
enviada: { color: 'bg-indigo-100 text-indigo-800', icon: Truck, text: 'Enviada' },
|
||||
entregada: { color: 'bg-green-100 text-green-800', icon: CheckCircle, text: 'Entregada' },
|
||||
cancelada: { color: 'bg-red-100 text-red-800', icon: XCircle, text: 'Cancelada' }
|
||||
};
|
||||
return statusMap[status] || statusMap.pendiente;
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter(order => {
|
||||
if (filter === 'all') return true;
|
||||
return order.estado === filter;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Órdenes</h1>
|
||||
<p className="text-gray-600 mt-1">Gestiona las órdenes de tus clientes</p>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{['all', 'pendiente', 'confirmada', 'preparando', 'enviada', 'entregada', 'cancelada'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors ${
|
||||
filter === status
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 border'
|
||||
}`}
|
||||
>
|
||||
{status === 'all' ? 'Todas' : getStatusInfo(status).text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabla de órdenes */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Orden</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cliente</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Total</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Estado</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Fecha</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredOrders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-6 py-12 text-center text-gray-500">
|
||||
No hay órdenes para mostrar
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredOrders.map((order) => {
|
||||
const statusInfo = getStatusInfo(order.estado);
|
||||
const StatusIcon = statusInfo.icon;
|
||||
return (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-medium text-gray-900">#{order.numero_orden}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{order.nombre_cliente}</div>
|
||||
<div className="text-sm text-gray-500">{order.email_cliente}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
${order.total?.toLocaleString('es-CL')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
<StatusIcon size={14} />
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString('es-CL')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<select
|
||||
value={order.estado}
|
||||
onChange={(e) => handleStatusChange(order.id, e.target.value)}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="pendiente">Pendiente</option>
|
||||
<option value="confirmada">Confirmada</option>
|
||||
<option value="preparando">Preparando</option>
|
||||
<option value="enviada">Enviada</option>
|
||||
<option value="entregada">Entregada</option>
|
||||
<option value="cancelada">Cancelada</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
270
administracion/src/pages/Products.jsx
Normal file
270
administracion/src/pages/Products.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, Image as ImageIcon, Printer } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { productService, categoryService } from '../services';
|
||||
import ProductModal from '../components/ProductModal';
|
||||
import PrintLabel from '../components/PrintLabel';
|
||||
|
||||
export default function Products() {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [printModalOpen, setPrintModalOpen] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [pagination, setPagination] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
loadProducts();
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadProducts = async (searchTerm = '') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await productService.getAll({ search: searchTerm, limit: 50 });
|
||||
setProducts(response.data);
|
||||
setPagination(response.pagination);
|
||||
} catch (err) {
|
||||
console.error('Error cargando productos:', err);
|
||||
toast.error('Error cargando productos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const response = await categoryService.getAll();
|
||||
setCategories(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error cargando categorías:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
const value = e.target.value;
|
||||
setSearch(value);
|
||||
if (value.length > 2 || value.length === 0) {
|
||||
loadProducts(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (product) => {
|
||||
setSelectedProduct(product);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePrint = (product) => {
|
||||
setSelectedProduct(product);
|
||||
setPrintModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (product) => {
|
||||
if (!confirm(`¿Eliminar producto "${product.nombre}"?`)) return;
|
||||
|
||||
try {
|
||||
await productService.delete(product.id);
|
||||
toast.success('Producto eliminado');
|
||||
loadProducts();
|
||||
} catch (err) {
|
||||
console.error('Error eliminando producto:', err);
|
||||
toast.error('Error al eliminar producto');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOpen(false);
|
||||
setSelectedProduct(null);
|
||||
loadProducts();
|
||||
};
|
||||
|
||||
const getCategoryName = (categoryId) => {
|
||||
if (!categories || categories.length === 0) return 'Sin categoría';
|
||||
const category = categories.find(c => c.id === categoryId);
|
||||
return category?.nombre || 'Sin categoría';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-gray-800">Productos</h1>
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Nuevo Producto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
placeholder="Buscar productos por nombre, SKU o descripción..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">No hay productos registrados</p>
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="mt-4 text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Crear primer producto
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Producto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
SKU
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Categoría
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Precio
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stock
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 flex-shrink-0 bg-gray-200 rounded flex items-center justify-center">
|
||||
{product.images?.length > 0 ? (
|
||||
<img
|
||||
src={`http://localhost:3001${product.images[0].url}`}
|
||||
alt={product.nombre}
|
||||
className="h-10 w-10 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon size={20} className="text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{product.nombre}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-gray-900 font-mono">{product.sku}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-gray-600">{getCategoryName(product.category_id)}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
${product.precio_base.toLocaleString('es-CL')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`text-sm font-medium ${
|
||||
product.stock_disponible === 0 ? 'text-red-600' :
|
||||
product.stock_disponible < 10 ? 'text-yellow-600' :
|
||||
'text-green-600'
|
||||
}`}>
|
||||
{product.stock_disponible || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
product.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{product.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handlePrint(product)}
|
||||
className="text-gray-600 hover:text-gray-900 mr-3"
|
||||
title="Imprimir etiqueta"
|
||||
>
|
||||
<Printer size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(product)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-3"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(product)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination Info */}
|
||||
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-700">
|
||||
Mostrando {products.length} de {pagination.totalItems} productos
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && (
|
||||
<ProductModal
|
||||
product={selectedProduct}
|
||||
categories={categories}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal de impresión */}
|
||||
{printModalOpen && selectedProduct && (
|
||||
<PrintLabel
|
||||
product={selectedProduct}
|
||||
onClose={() => {
|
||||
setPrintModalOpen(false);
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
administracion/src/pages/Settings.jsx
Normal file
142
administracion/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Settings, Store, CreditCard, Bell, Shield, Database } from 'lucide-react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Configuración</h1>
|
||||
<p className="text-gray-600 mt-1">Ajustes generales del sistema</p>
|
||||
</div>
|
||||
|
||||
{/* Settings Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Información General */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Store size={24} className="text-blue-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Información de la Tienda</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Nombre de la tienda</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue="OfertaWeb"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Email de contacto</label>
|
||||
<input
|
||||
type="email"
|
||||
defaultValue="contacto@ofertaweb.cl"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métodos de Pago */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<CreditCard size={24} className="text-green-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Métodos de Pago</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">Webpay/Transbank</span>
|
||||
<span className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-xs">
|
||||
No configurado
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">Transferencia Bancaria</span>
|
||||
<span className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-xs">
|
||||
No configurado
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notificaciones */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Bell size={24} className="text-yellow-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Notificaciones</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3">
|
||||
<input type="checkbox" defaultChecked className="rounded" disabled />
|
||||
<span className="text-sm text-gray-700">Email por nueva orden</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input type="checkbox" defaultChecked className="rounded" disabled />
|
||||
<span className="text-sm text-gray-700">Alerta de stock bajo</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input type="checkbox" className="rounded" disabled />
|
||||
<span className="text-sm text-gray-700">Reportes semanales</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seguridad */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Shield size={24} className="text-red-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Seguridad</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<button className="w-full text-left px-3 py-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors text-sm">
|
||||
Cambiar contraseña
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors text-sm">
|
||||
Gestionar usuarios
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors text-sm">
|
||||
Tokens de API
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Base de Datos */}
|
||||
<div className="bg-white rounded-lg shadow p-6 md:col-span-2">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Database size={24} className="text-purple-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Base de Datos</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-gray-50 rounded">
|
||||
<div className="text-2xl font-bold text-gray-800 mb-1">PostgreSQL</div>
|
||||
<div className="text-sm text-gray-600">Servidor de base de datos</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded">
|
||||
<div className="text-2xl font-bold text-gray-800 mb-1">ofertaweb</div>
|
||||
<div className="text-sm text-gray-600">Nombre de la base de datos</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded">
|
||||
<div className="text-2xl font-bold text-green-600 mb-1">Conectado</div>
|
||||
<div className="text-sm text-gray-600">Estado de conexión</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Settings size={20} className="text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-900 mb-1">Configuración en Desarrollo</h3>
|
||||
<p className="text-sm text-blue-700">
|
||||
Esta sección está en construcción. Las configuraciones se cargarán desde variables de entorno y base de datos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
administracion/src/pages/Shipments.jsx
Normal file
44
administracion/src/pages/Shipments.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Truck, Package, MapPin, Clock } from 'lucide-react';
|
||||
|
||||
export default function Shipments() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Envíos y Despachos</h1>
|
||||
<p className="text-gray-600 mt-1">Gestión de envíos y tracking de entregas</p>
|
||||
</div>
|
||||
|
||||
{/* Placeholder */}
|
||||
<div className="bg-white rounded-lg shadow p-12">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-blue-100 rounded-full mb-4">
|
||||
<Truck size={40} className="text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-2">Módulo de Envíos</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Próximamente: Integración con BlueExpress para gestión de envíos
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8 max-w-3xl mx-auto">
|
||||
<div className="p-6 bg-gray-50 rounded-lg">
|
||||
<Package size={32} className="text-blue-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-gray-800 mb-2">Crear Envíos</h3>
|
||||
<p className="text-sm text-gray-600">Genera órdenes de envío automáticas</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-lg">
|
||||
<MapPin size={32} className="text-blue-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-gray-800 mb-2">Tracking</h3>
|
||||
<p className="text-sm text-gray-600">Seguimiento en tiempo real</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-lg">
|
||||
<Clock size={32} className="text-blue-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-gray-800 mb-2">Historial</h3>
|
||||
<p className="text-sm text-gray-600">Registro de todos los envíos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
administracion/src/services/api.js
Normal file
37
administracion/src/services/api.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Interceptor para agregar el token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Interceptor para manejar errores
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
118
administracion/src/services/index.js
Normal file
118
administracion/src/services/index.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import api from './api';
|
||||
|
||||
export const authService = {
|
||||
login: async (email, password) => {
|
||||
const response = await api.post('/auth/login', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
register: async (userData) => {
|
||||
const response = await api.post('/auth/register', userData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getProfile: async () => {
|
||||
const response = await api.get('/auth/profile');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
export const productService = {
|
||||
getAll: async (params) => {
|
||||
const response = await api.get('/products', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id) => {
|
||||
const response = await api.get(`/products/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data) => {
|
||||
const response = await api.post('/products', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id, data) => {
|
||||
const response = await api.put(`/products/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id) => {
|
||||
const response = await api.delete(`/products/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addImage: async (id, formData) => {
|
||||
const response = await api.post(`/products/${id}/images`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
export const categoryService = {
|
||||
getAll: async () => {
|
||||
const response = await api.get('/categories');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTree: async () => {
|
||||
const response = await api.get('/categories/tree');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data) => {
|
||||
const response = await api.post('/categories', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id, data) => {
|
||||
const response = await api.put(`/categories/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id) => {
|
||||
const response = await api.delete(`/categories/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
export const inventoryService = {
|
||||
getByProduct: async (productId) => {
|
||||
const response = await api.get(`/inventory/product/${productId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
adjustStock: async (data) => {
|
||||
const response = await api.post('/inventory/adjust', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMovements: async (productId) => {
|
||||
const response = await api.get(`/inventory/product/${productId}/movements`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getLowStock: async () => {
|
||||
const response = await api.get('/inventory/low-stock');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
export const orderService = {
|
||||
getAll: async (params) => {
|
||||
const response = await api.get('/orders', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id) => {
|
||||
const response = await api.get(`/orders/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateStatus: async (id, estado) => {
|
||||
const response = await api.put(`/orders/${id}/status`, { estado });
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
29
administracion/src/store/authStore.js
Normal file
29
administracion/src/store/authStore.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export const useAuthStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: (user, token) => {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
set({ user, token, isAuthenticated: true });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
setUser: (user) => set({ user })
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage'
|
||||
}
|
||||
)
|
||||
);
|
||||
11
administracion/tailwind.config.js
Normal file
11
administracion/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
7
administracion/vite.config.js
Normal file
7
administracion/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
6
backend/.config
Normal file
6
backend/.config
Normal file
@@ -0,0 +1,6 @@
|
||||
DOMAIN=ofertaweb.cl
|
||||
PORT=3006
|
||||
DB_TYPE=postgresql
|
||||
BACKEND_TYPE=express
|
||||
PM2_APP_NAME=api-ofertaweb.cl
|
||||
INSTALL_DATE=Mon Dec 8 21:28:49 CET 2025
|
||||
3
backend/.gitignore
vendored
Normal file
3
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
logs/
|
||||
1
backend/.port
Normal file
1
backend/.port
Normal file
@@ -0,0 +1 @@
|
||||
3006
|
||||
229
backend/README.md
Normal file
229
backend/README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# OfertaWeb Backend
|
||||
|
||||
Backend API para tienda web multi-canal con integración a MercadoLibre, Instagram Shopping y BlueExpress.
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
|
||||
```
|
||||
backend/
|
||||
├── server.js # Punto de entrada del servidor
|
||||
├── package.json # Dependencias y scripts
|
||||
├── .env # Variables de entorno
|
||||
├── .gitignore # Archivos ignorados por git
|
||||
├── src/ # Código fuente
|
||||
│ ├── config/ # Configuraciones (DB, etc)
|
||||
│ ├── controllers/ # Controladores de rutas
|
||||
│ ├── middlewares/ # Middlewares (auth, validation, etc)
|
||||
│ ├── models/ # Modelos de datos
|
||||
│ ├── routes/ # Definición de rutas
|
||||
│ ├── services/ # Lógica de negocio
|
||||
│ └── utils/ # Utilidades (logger, helpers)
|
||||
├── migrations/ # Migraciones de base de datos
|
||||
├── scripts/ # Scripts de utilidad
|
||||
├── public/ # Archivos públicos
|
||||
│ └── uploads/ # Imágenes subidas
|
||||
├── logs/ # Logs del servidor
|
||||
└── tests/ # Tests unitarios e integración
|
||||
```
|
||||
|
||||
## 🚀 Inicio Rápido
|
||||
|
||||
### Requisitos Previos
|
||||
- Node.js >= 18.x
|
||||
- PostgreSQL >= 12
|
||||
- npm o yarn
|
||||
|
||||
### Instalación
|
||||
|
||||
1. **Instalar dependencias:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Configurar variables de entorno:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Editar .env con tus credenciales
|
||||
```
|
||||
|
||||
3. **Ejecutar migraciones:**
|
||||
```bash
|
||||
psql -U postgres -d ofertaweb -f migrations/001_schema_completo.sql
|
||||
psql -U postgres -d ofertaweb -f migrations/002_initial_data.sql
|
||||
psql -U postgres -d ofertaweb -f migrations/003_create_admin_user.sql
|
||||
psql -U postgres -d ofertaweb -f migrations/004_add_codigo_to_categories.sql
|
||||
```
|
||||
|
||||
4. **Iniciar servidor:**
|
||||
```bash
|
||||
# Desarrollo
|
||||
npm run dev
|
||||
|
||||
# Producción
|
||||
npm start
|
||||
```
|
||||
|
||||
El servidor estará disponible en `http://localhost:3001`
|
||||
|
||||
## 📋 Variables de Entorno
|
||||
|
||||
```env
|
||||
# Puerto del servidor
|
||||
PORT=3001
|
||||
|
||||
# Entorno
|
||||
NODE_ENV=development
|
||||
|
||||
# Base de datos
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=ofertaweb
|
||||
DB_USER=ofertaweb_user
|
||||
DB_PASSWORD=tu_password
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=tu_clave_secreta
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# CORS
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
ADMIN_URL=http://localhost:5174
|
||||
```
|
||||
|
||||
## 🛣️ API Endpoints
|
||||
|
||||
### Autenticación
|
||||
- `POST /api/auth/login` - Iniciar sesión
|
||||
- `POST /api/auth/register` - Registrar usuario
|
||||
- `GET /api/auth/me` - Obtener usuario actual
|
||||
|
||||
### Productos
|
||||
- `GET /api/products` - Listar productos
|
||||
- `GET /api/products/:id` - Obtener producto
|
||||
- `POST /api/products` - Crear producto (admin)
|
||||
- `PUT /api/products/:id` - Actualizar producto (admin)
|
||||
- `DELETE /api/products/:id` - Eliminar producto (admin)
|
||||
|
||||
### Categorías
|
||||
- `GET /api/categories` - Listar categorías
|
||||
- `GET /api/categories/tree` - Árbol de categorías
|
||||
- `POST /api/categories` - Crear categoría (admin)
|
||||
- `PUT /api/categories/:id` - Actualizar categoría (admin)
|
||||
|
||||
### Inventario
|
||||
- `GET /api/inventory/:productId` - Ver inventario
|
||||
- `POST /api/inventory/adjust` - Ajustar stock (admin)
|
||||
- `GET /api/inventory/movements/:productId` - Historial de movimientos
|
||||
|
||||
### Órdenes
|
||||
- `GET /api/orders` - Listar órdenes
|
||||
- `GET /api/orders/:id` - Detalle de orden
|
||||
- `POST /api/orders` - Crear orden
|
||||
- `PATCH /api/orders/:id/status` - Actualizar estado (admin)
|
||||
|
||||
### Carrito
|
||||
- `GET /api/cart` - Ver carrito
|
||||
- `POST /api/cart/items` - Agregar producto
|
||||
- `PUT /api/cart/items/:productId` - Actualizar cantidad
|
||||
- `DELETE /api/cart/items/:productId` - Eliminar del carrito
|
||||
|
||||
### Uploads
|
||||
- `POST /api/upload/product/:productId` - Subir imagen de producto
|
||||
- `DELETE /api/upload/image/:imageId` - Eliminar imagen
|
||||
|
||||
## 🗄️ Base de Datos
|
||||
|
||||
### Módulos
|
||||
1. **Usuarios y Autenticación** - users, user_addresses
|
||||
2. **Catálogo** - categories, products, product_images
|
||||
3. **Multi-Canal** - channels, channel_products
|
||||
4. **Inventario** - inventory, inventory_movements
|
||||
5. **Carrito** - carts, cart_items
|
||||
6. **Órdenes** - orders, order_items, order_status_history
|
||||
7. **Pagos** - payments
|
||||
8. **Envíos** - shipments
|
||||
|
||||
## 🔧 Scripts Disponibles
|
||||
|
||||
```bash
|
||||
# Desarrollo con auto-reload
|
||||
npm run dev
|
||||
|
||||
# Iniciar en producción
|
||||
npm start
|
||||
|
||||
# Ejecutar tests
|
||||
npm test
|
||||
|
||||
# Generar hash de contraseña
|
||||
node scripts/generate_password_hash.js
|
||||
```
|
||||
|
||||
## 📝 Logs
|
||||
|
||||
Los logs se almacenan en la carpeta `logs/`:
|
||||
- `error.log` - Solo errores
|
||||
- `combined.log` - Todos los logs
|
||||
|
||||
## 🔐 Seguridad
|
||||
|
||||
- Autenticación mediante JWT
|
||||
- Contraseñas hasheadas con bcrypt
|
||||
- Validación de datos con Joi
|
||||
- Protección CORS configurada
|
||||
- Rate limiting implementado
|
||||
- SQL injection prevention con prepared statements
|
||||
|
||||
## 🚧 Desarrollo
|
||||
|
||||
### Agregar nueva ruta
|
||||
|
||||
1. Crear controlador en `src/controllers/`
|
||||
2. Crear modelo en `src/models/` (si aplica)
|
||||
3. Definir ruta en `src/routes/`
|
||||
4. Registrar en `server.js`
|
||||
|
||||
### Ejemplo:
|
||||
```javascript
|
||||
// src/controllers/ejemploController.js
|
||||
const db = require('../config/db');
|
||||
|
||||
exports.getEjemplo = async (req, res) => {
|
||||
const result = await db.query('SELECT * FROM tabla');
|
||||
res.json(result.rows);
|
||||
};
|
||||
|
||||
// src/routes/ejemploRoutes.js
|
||||
const router = require('express').Router();
|
||||
const controller = require('../controllers/ejemploController');
|
||||
|
||||
router.get('/', controller.getEjemplo);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// server.js
|
||||
const ejemploRoutes = require('./src/routes/ejemploRoutes');
|
||||
app.use('/api/ejemplo', ejemploRoutes);
|
||||
```
|
||||
|
||||
## 📦 Dependencias Principales
|
||||
|
||||
- **express** - Framework web
|
||||
- **pg** - Cliente PostgreSQL
|
||||
- **jsonwebtoken** - Autenticación JWT
|
||||
- **bcryptjs** - Hash de contraseñas
|
||||
- **multer** - Upload de archivos
|
||||
- **winston** - Sistema de logs
|
||||
- **joi** - Validación de datos
|
||||
- **cors** - Configuración CORS
|
||||
|
||||
## 🤝 Contribuir
|
||||
|
||||
1. Crear rama feature: `git checkout -b feature/nueva-funcionalidad`
|
||||
2. Hacer cambios y commit: `git commit -m 'Agregar nueva funcionalidad'`
|
||||
3. Push a la rama: `git push origin feature/nueva-funcionalidad`
|
||||
4. Crear Pull Request
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
ISC
|
||||
419
backend/migrations/001_schema_completo.sql
Normal file
419
backend/migrations/001_schema_completo.sql
Normal file
@@ -0,0 +1,419 @@
|
||||
-- ================================================
|
||||
-- OFERTAWEB.CL - ESQUEMA DE BASE DE DATOS
|
||||
-- Sistema E-Commerce Multi-Canal
|
||||
-- PostgreSQL 12+
|
||||
-- ================================================
|
||||
|
||||
-- ================================================
|
||||
-- 1. MÓDULO: USUARIOS Y AUTENTICACIÓN
|
||||
-- ================================================
|
||||
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nombre VARCHAR(100) NOT NULL,
|
||||
apellido VARCHAR(100),
|
||||
telefono VARCHAR(20),
|
||||
role VARCHAR(20) DEFAULT 'cliente' CHECK (role IN ('cliente', 'admin', 'repartidor')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_addresses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
direccion TEXT NOT NULL,
|
||||
comuna VARCHAR(100) NOT NULL,
|
||||
ciudad VARCHAR(100) NOT NULL,
|
||||
region VARCHAR(100) NOT NULL,
|
||||
codigo_postal VARCHAR(20),
|
||||
referencia TEXT,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_addresses_user_id ON user_addresses(user_id);
|
||||
|
||||
-- ================================================
|
||||
-- 2. MÓDULO: CATÁLOGO DE PRODUCTOS
|
||||
-- ================================================
|
||||
|
||||
CREATE TABLE categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nombre VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
descripcion TEXT,
|
||||
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
orden INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sku VARCHAR(50) UNIQUE NOT NULL,
|
||||
nombre VARCHAR(255) NOT NULL,
|
||||
descripcion TEXT,
|
||||
precio_base DECIMAL(10, 2) NOT NULL,
|
||||
peso_gramos INTEGER,
|
||||
alto_cm DECIMAL(6, 2),
|
||||
ancho_cm DECIMAL(6, 2),
|
||||
largo_cm DECIMAL(6, 2),
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE product_images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
alt_text VARCHAR(255),
|
||||
orden INTEGER DEFAULT 0,
|
||||
is_primary BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_product_images_product_id ON product_images(product_id);
|
||||
CREATE INDEX idx_products_category_id ON products(category_id);
|
||||
CREATE INDEX idx_products_sku ON products(sku);
|
||||
|
||||
-- ================================================
|
||||
-- 3. MÓDULO: MULTI-CANAL
|
||||
-- ================================================
|
||||
|
||||
CREATE TABLE channels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nombre VARCHAR(50) NOT NULL CHECK (nombre IN ('tienda', 'mercadolibre', 'instagram')),
|
||||
slug VARCHAR(50) UNIQUE NOT NULL,
|
||||
config JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insertar canales por defecto
|
||||
INSERT INTO channels (nombre, slug) VALUES
|
||||
('tienda', 'tienda'),
|
||||
('mercadolibre', 'mercadolibre'),
|
||||
('instagram', 'instagram');
|
||||
|
||||
CREATE TABLE channel_products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
external_id VARCHAR(100),
|
||||
precio_ajustado DECIMAL(10, 2),
|
||||
precio_factor DECIMAL(5, 2) DEFAULT 1.00,
|
||||
estado VARCHAR(20) DEFAULT 'publicado' CHECK (estado IN ('publicado', 'pausado', 'agotado')),
|
||||
last_sync_at TIMESTAMP,
|
||||
sync_errors JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(product_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE TABLE channel_sync_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
|
||||
tipo VARCHAR(20) CHECK (tipo IN ('stock', 'precio', 'publicacion')),
|
||||
status VARCHAR(20) CHECK (status IN ('success', 'error')),
|
||||
mensaje TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_products_product_id ON channel_products(product_id);
|
||||
CREATE INDEX idx_channel_products_channel_id ON channel_products(channel_id);
|
||||
CREATE INDEX idx_channel_sync_logs_created_at ON channel_sync_logs(created_at);
|
||||
|
||||
-- ================================================
|
||||
-- 4. MÓDULO: INVENTARIO (STOCK UNIFICADO)
|
||||
-- ================================================
|
||||
|
||||
CREATE TABLE inventory (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER UNIQUE NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
stock_actual INTEGER DEFAULT 0 CHECK (stock_actual >= 0),
|
||||
stock_minimo INTEGER DEFAULT 5,
|
||||
stock_reservado INTEGER DEFAULT 0 CHECK (stock_reservado >= 0),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE inventory_movements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
tipo VARCHAR(20) CHECK (tipo IN ('entrada', 'salida', 'ajuste', 'reserva', 'liberacion')),
|
||||
cantidad INTEGER NOT NULL,
|
||||
cantidad_anterior INTEGER NOT NULL,
|
||||
cantidad_nueva INTEGER NOT NULL,
|
||||
referencia VARCHAR(100),
|
||||
notas TEXT,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_inventory_product_id ON inventory(product_id);
|
||||
CREATE INDEX idx_inventory_movements_product_id ON inventory_movements(product_id);
|
||||
CREATE INDEX idx_inventory_movements_created_at ON inventory_movements(created_at);
|
||||
|
||||
-- ================================================
|
||||
-- 5. MÓDULO: CARRITO DE COMPRAS
|
||||
-- ================================================
|
||||
|
||||
CREATE TABLE carts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
session_id VARCHAR(100),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE cart_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
cantidad INTEGER NOT NULL CHECK (cantidad > 0),
|
||||
precio_unitario DECIMAL(10, 2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_carts_user_id ON carts(user_id);
|
||||
CREATE INDEX idx_carts_session_id ON carts(session_id);
|
||||
CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id);
|
||||
|
||||
-- ================================================
|
||||
-- 6. MÓDULO: ÓRDENES DE COMPRA
|
||||
-- ================================================
|
||||
|
||||
CREATE TABLE orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE RESTRICT,
|
||||
estado VARCHAR(30) DEFAULT 'pendiente_pago'
|
||||
CHECK (estado IN ('pendiente_pago', 'pagada', 'preparando', 'enviada', 'entregada', 'cancelada')),
|
||||
subtotal DECIMAL(10, 2) NOT NULL,
|
||||
descuento DECIMAL(10, 2) DEFAULT 0,
|
||||
costo_envio DECIMAL(10, 2) DEFAULT 0,
|
||||
total DECIMAL(10, 2) NOT NULL,
|
||||
notas_cliente TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE order_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
|
||||
cantidad INTEGER NOT NULL CHECK (cantidad > 0),
|
||||
precio_unitario DECIMAL(10, 2) NOT NULL,
|
||||
subtotal DECIMAL(10, 2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX idx_orders_order_number ON orders(order_number);
|
||||
CREATE INDEX idx_orders_estado ON orders(estado);
|
||||
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
|
||||
|
||||
-- ================================================
|
||||
-- 7. MÓDULO: PAGOS
|
||||
-- ================================================
|
||||
|
||||
CREATE TABLE payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id INTEGER UNIQUE NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
|
||||
metodo VARCHAR(30) CHECK (metodo IN ('webpay', 'transferencia', 'mercadopago', 'efectivo')),
|
||||
estado VARCHAR(20) DEFAULT 'pendiente'
|
||||
CHECK (estado IN ('pendiente', 'aprobado', 'rechazado', 'reembolsado')),
|
||||
monto DECIMAL(10, 2) NOT NULL,
|
||||
transaction_id VARCHAR(100),
|
||||
response_data JSONB,
|
||||
paid_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE payment_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
payment_id INTEGER NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
|
||||
tipo VARCHAR(20) CHECK (tipo IN ('authorization', 'capture', 'refund')),
|
||||
status VARCHAR(20),
|
||||
request_data JSONB,
|
||||
response_data JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_payments_order_id ON payments(order_id);
|
||||
CREATE INDEX idx_payment_transactions_payment_id ON payment_transactions(payment_id);
|
||||
|
||||
-- ================================================
|
||||
-- 8. MÓDULO: DESPACHO Y ENVÍOS
|
||||
-- ================================================
|
||||
|
||||
CREATE TABLE shipments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id INTEGER UNIQUE NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
|
||||
tracking_number VARCHAR(100),
|
||||
courier VARCHAR(30) CHECK (courier IN ('bluexpress', 'chilexpress', 'starken', 'retiro')),
|
||||
estado VARCHAR(30) DEFAULT 'pendiente'
|
||||
CHECK (estado IN ('pendiente', 'preparando', 'en_transito', 'en_reparto', 'entregado', 'fallido')),
|
||||
direccion_id INTEGER REFERENCES user_addresses(id) ON DELETE RESTRICT,
|
||||
costo_calculado DECIMAL(10, 2),
|
||||
peso_total INTEGER,
|
||||
dimensiones JSONB,
|
||||
fecha_estimada_entrega DATE,
|
||||
fecha_entrega_real TIMESTAMP,
|
||||
repartidor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
notas_despacho TEXT,
|
||||
comprobante_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE shipment_tracking (
|
||||
id SERIAL PRIMARY KEY,
|
||||
shipment_id INTEGER NOT NULL REFERENCES shipments(id) ON DELETE CASCADE,
|
||||
estado VARCHAR(30) NOT NULL,
|
||||
ubicacion VARCHAR(255),
|
||||
descripcion TEXT,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE shipping_quotes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
|
||||
courier VARCHAR(30) NOT NULL,
|
||||
servicio VARCHAR(50),
|
||||
origen_comuna VARCHAR(100),
|
||||
destino_comuna VARCHAR(100),
|
||||
peso_gramos INTEGER NOT NULL,
|
||||
costo DECIMAL(10, 2) NOT NULL,
|
||||
dias_estimados INTEGER,
|
||||
response_data JSONB,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_shipments_order_id ON shipments(order_id);
|
||||
CREATE INDEX idx_shipments_estado ON shipments(estado);
|
||||
CREATE INDEX idx_shipment_tracking_shipment_id ON shipment_tracking(shipment_id);
|
||||
|
||||
-- ================================================
|
||||
-- 9. MÓDULO: CONFIGURACIÓN DEL SISTEMA
|
||||
-- ================================================
|
||||
|
||||
CREATE TABLE settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key VARCHAR(100) UNIQUE NOT NULL,
|
||||
value TEXT,
|
||||
tipo VARCHAR(20) DEFAULT 'string' CHECK (tipo IN ('string', 'number', 'boolean', 'json')),
|
||||
descripcion TEXT,
|
||||
is_secret BOOLEAN DEFAULT false,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ================================================
|
||||
-- FUNCIONES Y TRIGGERS
|
||||
-- ================================================
|
||||
|
||||
-- Trigger para actualizar updated_at automáticamente
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Aplicar trigger a las tablas relevantes
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_products_updated_at BEFORE UPDATE ON products
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_inventory_updated_at BEFORE UPDATE ON inventory
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Función para generar número de orden
|
||||
CREATE OR REPLACE FUNCTION generate_order_number()
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
new_number TEXT;
|
||||
date_part TEXT;
|
||||
sequence_part TEXT;
|
||||
count_today INTEGER;
|
||||
BEGIN
|
||||
date_part := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
|
||||
|
||||
SELECT COUNT(*) INTO count_today
|
||||
FROM orders
|
||||
WHERE order_number LIKE 'ORD-' || date_part || '-%';
|
||||
|
||||
sequence_part := LPAD((count_today + 1)::TEXT, 4, '0');
|
||||
new_number := 'ORD-' || date_part || '-' || sequence_part;
|
||||
|
||||
RETURN new_number;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ================================================
|
||||
-- VISTAS ÚTILES
|
||||
-- ================================================
|
||||
|
||||
-- Vista de productos con stock disponible
|
||||
CREATE VIEW v_products_with_stock AS
|
||||
SELECT
|
||||
p.id,
|
||||
p.sku,
|
||||
p.nombre,
|
||||
p.precio_base,
|
||||
p.is_active,
|
||||
i.stock_actual,
|
||||
i.stock_reservado,
|
||||
(i.stock_actual - i.stock_reservado) as stock_disponible,
|
||||
c.nombre as categoria
|
||||
FROM products p
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
LEFT JOIN categories c ON p.category_id = c.id;
|
||||
|
||||
-- Vista de órdenes con información completa
|
||||
CREATE VIEW v_orders_complete AS
|
||||
SELECT
|
||||
o.id,
|
||||
o.order_number,
|
||||
o.estado,
|
||||
o.total,
|
||||
o.created_at,
|
||||
u.nombre || ' ' || COALESCE(u.apellido, '') as cliente,
|
||||
u.email,
|
||||
ch.nombre as canal,
|
||||
p.estado as estado_pago,
|
||||
s.tracking_number,
|
||||
s.estado as estado_envio
|
||||
FROM orders o
|
||||
JOIN users u ON o.user_id = u.id
|
||||
JOIN channels ch ON o.channel_id = ch.id
|
||||
LEFT JOIN payments p ON o.id = p.order_id
|
||||
LEFT JOIN shipments s ON o.id = s.order_id;
|
||||
|
||||
-- ================================================
|
||||
-- DATOS DE PRUEBA (OPCIONAL)
|
||||
-- ================================================
|
||||
|
||||
-- Usuario admin por defecto (password: admin123)
|
||||
INSERT INTO users (email, password_hash, nombre, apellido, role) VALUES
|
||||
('admin@ofertaweb.cl', '$2a$10$X6vE7F.QqP8F5F5F5F5F5eK9Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z1Z', 'Admin', 'Sistema', 'admin');
|
||||
|
||||
-- Categorías de ejemplo
|
||||
INSERT INTO categories (nombre, slug, descripcion, orden) VALUES
|
||||
('Electrónica', 'electronica', 'Productos electrónicos y tecnología', 1),
|
||||
('Ropa', 'ropa', 'Vestimenta y accesorios', 2),
|
||||
('Hogar', 'hogar', 'Artículos para el hogar', 3);
|
||||
|
||||
COMMIT;
|
||||
25
backend/migrations/002_create_user.sql
Normal file
25
backend/migrations/002_create_user.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- ================================================
|
||||
-- CREAR USUARIO Y DAR PERMISOS
|
||||
-- Ejecutar como usuario postgres en pgAdmin4
|
||||
-- ================================================
|
||||
|
||||
-- 1. Crear usuario
|
||||
CREATE USER ofertaweb_user WITH PASSWORD 'OfertaWeb2024!';
|
||||
|
||||
-- 2. Dar permisos sobre la base de datos
|
||||
GRANT ALL PRIVILEGES ON DATABASE ofertaweb TO ofertaweb_user;
|
||||
|
||||
-- 3. Conectarse a la base de datos ofertaweb y ejecutar:
|
||||
-- (En pgAdmin4: click derecho en base "ofertaweb" -> Query Tool y ejecutar lo siguiente)
|
||||
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ofertaweb_user;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ofertaweb_user;
|
||||
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO ofertaweb_user;
|
||||
|
||||
-- 4. Para tablas futuras
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ofertaweb_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ofertaweb_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO ofertaweb_user;
|
||||
|
||||
-- Verificar que se creó el usuario
|
||||
SELECT usename FROM pg_user WHERE usename = 'ofertaweb_user';
|
||||
38
backend/migrations/003_create_admin_user.sql
Normal file
38
backend/migrations/003_create_admin_user.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- ================================================
|
||||
-- CREAR USUARIO ADMINISTRADOR
|
||||
-- Ejecutar en la base de datos ofertaweb
|
||||
-- ================================================
|
||||
|
||||
-- Usuario: admin@ofertaweb.cl
|
||||
-- Contraseña: admin123
|
||||
-- Hash bcrypt generado con bcryptjs (10 rounds)
|
||||
|
||||
INSERT INTO users (
|
||||
email,
|
||||
password_hash,
|
||||
nombre,
|
||||
apellido,
|
||||
telefono,
|
||||
role,
|
||||
is_active,
|
||||
email_verificado
|
||||
) VALUES (
|
||||
'admin@ofertaweb.cl',
|
||||
'$2a$10$zt.nWuEczQFQAwFFjuuPX.cx6cgSqSYZpYmcaEZdRSn5XRNVFI4YS',
|
||||
'Administrador',
|
||||
'Sistema',
|
||||
'+56912345678',
|
||||
'admin',
|
||||
true,
|
||||
true
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role,
|
||||
is_active = EXCLUDED.is_active,
|
||||
email_verificado = EXCLUDED.email_verificado;
|
||||
|
||||
-- Verificar que se creó
|
||||
SELECT id, email, nombre, apellido, role, is_active, email_verificado, created_at
|
||||
FROM users
|
||||
WHERE email = 'admin@ofertaweb.cl';
|
||||
13
backend/migrations/004_add_codigo_to_categories.sql
Normal file
13
backend/migrations/004_add_codigo_to_categories.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Agregar campo codigo a categorías para generar SKUs automáticos
|
||||
ALTER TABLE categories
|
||||
ADD COLUMN IF NOT EXISTS codigo VARCHAR(10);
|
||||
|
||||
-- Crear índice para búsquedas rápidas
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_codigo ON categories(codigo);
|
||||
|
||||
-- Generar códigos automáticos para categorías existentes
|
||||
UPDATE categories
|
||||
SET codigo = UPPER(LEFT(nombre, 4))
|
||||
WHERE codigo IS NULL;
|
||||
|
||||
COMMENT ON COLUMN categories.codigo IS 'Código corto para generar SKUs de productos (ej: ELEC, ROPA)';
|
||||
6261
backend/package-lock.json
generated
Normal file
6261
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
backend/package.json
Normal file
41
backend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "ofertaweb-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API para tienda web multi-canal",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "jest --coverage"
|
||||
},
|
||||
"keywords": [
|
||||
"ecommerce",
|
||||
"api",
|
||||
"mercadolibre",
|
||||
"instagram"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mercadolibre": "^0.0.13",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.7",
|
||||
"pg": "^8.16.3",
|
||||
"sharp": "^0.33.1",
|
||||
"winston": "^3.11.0",
|
||||
"helmet": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
1
backend/public/uploads/.gitkeep
Normal file
1
backend/public/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Este archivo mantiene el directorio en git
|
||||
75
backend/scripts/create_sample_product.sql
Normal file
75
backend/scripts/create_sample_product.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
-- Agregar campo codigo si no existe
|
||||
ALTER TABLE categories
|
||||
ADD COLUMN IF NOT EXISTS codigo VARCHAR(10);
|
||||
|
||||
-- Crear categoría de ejemplo si no existe
|
||||
INSERT INTO categories (nombre, slug, descripcion, codigo, orden)
|
||||
VALUES ('Electrónica', 'electronica', 'Productos electrónicos y tecnología', 'ELEC', 1)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
-- Obtener el ID de la categoría
|
||||
DO $$
|
||||
DECLARE
|
||||
cat_id INTEGER;
|
||||
prod_id INTEGER;
|
||||
BEGIN
|
||||
-- Buscar categoría
|
||||
SELECT id INTO cat_id FROM categories WHERE slug = 'electronica';
|
||||
|
||||
-- Crear producto de ejemplo
|
||||
INSERT INTO products (
|
||||
sku,
|
||||
nombre,
|
||||
descripcion,
|
||||
category_id,
|
||||
precio_base,
|
||||
peso_gramos,
|
||||
alto_cm,
|
||||
ancho_cm,
|
||||
largo_cm,
|
||||
is_active
|
||||
)
|
||||
VALUES (
|
||||
'ELEC-001',
|
||||
'Mouse Inalámbrico Logitech M185',
|
||||
'Mouse inalámbrico con sensor óptico de alta precisión, batería de larga duración (hasta 12 meses), receptor nano USB. Color negro.',
|
||||
cat_id,
|
||||
12990,
|
||||
85,
|
||||
3.8,
|
||||
6.2,
|
||||
10.5,
|
||||
true
|
||||
)
|
||||
ON CONFLICT (sku) DO UPDATE
|
||||
SET nombre = EXCLUDED.nombre,
|
||||
descripcion = EXCLUDED.descripcion,
|
||||
precio_base = EXCLUDED.precio_base
|
||||
RETURNING id INTO prod_id;
|
||||
|
||||
-- Crear inventario inicial
|
||||
INSERT INTO inventory (product_id, stock_actual, stock_minimo)
|
||||
VALUES (prod_id, 25, 5)
|
||||
ON CONFLICT (product_id) DO UPDATE
|
||||
SET stock_actual = 25;
|
||||
|
||||
-- Registrar movimiento de inventario
|
||||
INSERT INTO inventory_movements (
|
||||
product_id,
|
||||
tipo,
|
||||
cantidad,
|
||||
cantidad_anterior,
|
||||
cantidad_nueva,
|
||||
notas
|
||||
)
|
||||
VALUES (
|
||||
prod_id,
|
||||
'entrada',
|
||||
25,
|
||||
0,
|
||||
25,
|
||||
'Stock inicial de ejemplo'
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Producto creado exitosamente: ELEC-001 con 25 unidades en stock';
|
||||
END $$;
|
||||
34
backend/scripts/generate_password_hash.js
Normal file
34
backend/scripts/generate_password_hash.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// Script para generar hash de contraseña
|
||||
// Uso: node generate_password_hash.js <password>
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const password = process.argv[2] || 'admin123';
|
||||
const saltRounds = 10;
|
||||
|
||||
bcrypt.hash(password, saltRounds, (err, hash) => {
|
||||
if (err) {
|
||||
console.error('Error generando hash:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nContraseña:', password);
|
||||
console.log('Hash bcrypt:', hash);
|
||||
console.log('\nSQL para insertar usuario:');
|
||||
console.log(`
|
||||
INSERT INTO users (email, password_hash, nombre, apellido, telefono, role, is_active, email_verificado)
|
||||
VALUES (
|
||||
'admin@ofertaweb.cl',
|
||||
'${hash}',
|
||||
'Administrador',
|
||||
'Sistema',
|
||||
'+56912345678',
|
||||
'admin',
|
||||
true,
|
||||
true
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role,
|
||||
is_active = EXCLUDED.is_active;
|
||||
`);
|
||||
});
|
||||
86
backend/server.js
Normal file
86
backend/server.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
require('dotenv').config({ path: __dirname + '/.env' });
|
||||
|
||||
const errorHandler = require('./src/middlewares/errorHandler');
|
||||
const logger = require('./src/utils/logger');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middlewares globales
|
||||
app.use(cors({
|
||||
origin: [process.env.FRONTEND_URL, process.env.ADMIN_URL],
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Servir archivos estáticos (imágenes de productos)
|
||||
app.use('/uploads', express.static('public/uploads'));
|
||||
|
||||
// Logger de requests
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Rutas
|
||||
const authRoutes = require('./src/routes/authRoutes');
|
||||
const productRoutes = require('./src/routes/productRoutes');
|
||||
const categoryRoutes = require('./src/routes/categoryRoutes');
|
||||
const inventoryRoutes = require('./src/routes/inventoryRoutes');
|
||||
const orderRoutes = require('./src/routes/orderRoutes');
|
||||
const cartRoutes = require('./src/routes/cartRoutes');
|
||||
const uploadRoutes = require('./src/routes/uploadRoutes');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/products', productRoutes);
|
||||
app.use('/api/categories', categoryRoutes);
|
||||
app.use('/api/inventory', inventoryRoutes);
|
||||
app.use('/api/orders', orderRoutes);
|
||||
app.use('/api/cart', cartRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
environment: process.env.NODE_ENV,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Ruta raíz
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'OfertaWeb API',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
auth: '/api/auth',
|
||||
products: '/api/products',
|
||||
categories: '/api/categories',
|
||||
inventory: '/api/inventory',
|
||||
orders: '/api/orders',
|
||||
cart: '/api/cart',
|
||||
upload: '/api/upload'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Manejo de rutas no encontradas
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Ruta no encontrada' });
|
||||
});
|
||||
|
||||
// Middleware de manejo de errores (debe estar al final)
|
||||
app.use(errorHandler);
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Servidor OfertaWeb corriendo en puerto ${PORT}`);
|
||||
console.log(`📝 Ambiente: ${process.env.NODE_ENV || 'development'}`);
|
||||
logger.info(`Servidor iniciado en puerto ${PORT}`);
|
||||
});
|
||||
89
backend/src/config/constants.js
Normal file
89
backend/src/config/constants.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// Constantes del sistema
|
||||
module.exports = {
|
||||
// Roles de usuario
|
||||
ROLES: {
|
||||
ADMIN: 'admin',
|
||||
CLIENTE: 'cliente',
|
||||
REPARTIDOR: 'repartidor'
|
||||
},
|
||||
|
||||
// Estados de órdenes
|
||||
ORDER_STATUS: {
|
||||
PENDIENTE_PAGO: 'pendiente_pago',
|
||||
PAGADA: 'pagada',
|
||||
PREPARANDO: 'preparando',
|
||||
ENVIADA: 'enviada',
|
||||
ENTREGADA: 'entregada',
|
||||
CANCELADA: 'cancelada'
|
||||
},
|
||||
|
||||
// Estados de despacho
|
||||
SHIPMENT_STATUS: {
|
||||
PENDIENTE: 'pendiente',
|
||||
PREPARANDO: 'preparando',
|
||||
EN_TRANSITO: 'en_transito',
|
||||
EN_REPARTO: 'en_reparto',
|
||||
ENTREGADO: 'entregado',
|
||||
FALLIDO: 'fallido'
|
||||
},
|
||||
|
||||
// Estados de pago
|
||||
PAYMENT_STATUS: {
|
||||
PENDIENTE: 'pendiente',
|
||||
APROBADO: 'aprobado',
|
||||
RECHAZADO: 'rechazado',
|
||||
REEMBOLSADO: 'reembolsado'
|
||||
},
|
||||
|
||||
// Métodos de pago
|
||||
PAYMENT_METHODS: {
|
||||
WEBPAY: 'webpay',
|
||||
TRANSFERENCIA: 'transferencia',
|
||||
MERCADOPAGO: 'mercadopago',
|
||||
EFECTIVO: 'efectivo'
|
||||
},
|
||||
|
||||
// Canales de venta
|
||||
CHANNELS: {
|
||||
TIENDA: 'tienda',
|
||||
MERCADOLIBRE: 'mercadolibre',
|
||||
INSTAGRAM: 'instagram'
|
||||
},
|
||||
|
||||
// Couriers
|
||||
COURIERS: {
|
||||
BLUEXPRESS: 'bluexpress',
|
||||
CHILEXPRESS: 'chilexpress',
|
||||
STARKEN: 'starken',
|
||||
RETIRO: 'retiro'
|
||||
},
|
||||
|
||||
// Tipos de movimiento de inventario
|
||||
INVENTORY_MOVEMENT_TYPES: {
|
||||
ENTRADA: 'entrada',
|
||||
SALIDA: 'salida',
|
||||
AJUSTE: 'ajuste',
|
||||
RESERVA: 'reserva',
|
||||
LIBERACION: 'liberacion'
|
||||
},
|
||||
|
||||
// Configuración de stock
|
||||
STOCK: {
|
||||
MIN_STOCK_ALERT: 5,
|
||||
RESERVATION_TIMEOUT_MINUTES: 15
|
||||
},
|
||||
|
||||
// Configuración de imágenes
|
||||
IMAGES: {
|
||||
MAX_SIZE: 5 * 1024 * 1024, // 5MB
|
||||
ALLOWED_TYPES: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
MAX_PER_PRODUCT: 10
|
||||
},
|
||||
|
||||
// Paginación
|
||||
PAGINATION: {
|
||||
DEFAULT_PAGE: 1,
|
||||
DEFAULT_LIMIT: 20,
|
||||
MAX_LIMIT: 100
|
||||
}
|
||||
};
|
||||
23
backend/src/config/db.js
Normal file
23
backend/src/config/db.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
pool.on('connect', () => {
|
||||
console.log('Conexión PostgreSQL exitosa');
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Error inesperado en PostgreSQL:', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
42
backend/src/config/multer.js
Normal file
42
backend/src/config/multer.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { IMAGES } = require('./constants');
|
||||
|
||||
// Crear directorio de uploads si no existe
|
||||
const uploadDir = path.join(__dirname, '..', 'public', 'uploads', 'products');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Configuración de almacenamiento
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, 'product-' + uniqueSuffix + ext);
|
||||
}
|
||||
});
|
||||
|
||||
// Filtro de archivos
|
||||
const fileFilter = (req, file, cb) => {
|
||||
if (IMAGES.ALLOWED_TYPES.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Tipo de archivo no permitido. Solo se aceptan imágenes JPEG, PNG y WebP.'), false);
|
||||
}
|
||||
};
|
||||
|
||||
// Configuración de multer
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: IMAGES.MAX_SIZE
|
||||
},
|
||||
fileFilter: fileFilter
|
||||
});
|
||||
|
||||
module.exports = upload;
|
||||
116
backend/src/controllers/authController.js
Normal file
116
backend/src/controllers/authController.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
const { sanitizeUser } = require('../utils/helpers');
|
||||
const { asyncHandler } = require('../utils/helpers');
|
||||
|
||||
// Registro de usuario
|
||||
exports.register = asyncHandler(async (req, res) => {
|
||||
const { email, password, nombre, apellido, telefono } = req.body;
|
||||
|
||||
// Verificar si el email ya existe
|
||||
const existingUser = await User.findByEmail(email);
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'El email ya está registrado' });
|
||||
}
|
||||
|
||||
// Crear usuario
|
||||
const user = await User.create({
|
||||
email,
|
||||
password,
|
||||
nombre,
|
||||
apellido,
|
||||
telefono,
|
||||
role: 'cliente'
|
||||
});
|
||||
|
||||
// Generar token
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, email: user.email, role: user.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Usuario registrado exitosamente',
|
||||
user: sanitizeUser(user),
|
||||
token
|
||||
});
|
||||
});
|
||||
|
||||
// Login
|
||||
exports.login = asyncHandler(async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Buscar usuario
|
||||
const user = await User.findByEmail(email);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||
}
|
||||
|
||||
// Verificar contraseña
|
||||
const isValidPassword = await User.verifyPassword(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||
}
|
||||
|
||||
// Verificar si está activo
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({ error: 'Usuario inactivo. Contacta a soporte.' });
|
||||
}
|
||||
|
||||
// Generar token
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, email: user.email, role: user.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Login exitoso',
|
||||
user: sanitizeUser(user),
|
||||
token
|
||||
});
|
||||
});
|
||||
|
||||
// Obtener perfil del usuario autenticado
|
||||
exports.getProfile = asyncHandler(async (req, res) => {
|
||||
const user = await User.findById(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Usuario no encontrado' });
|
||||
}
|
||||
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
// Actualizar perfil
|
||||
exports.updateProfile = asyncHandler(async (req, res) => {
|
||||
const { nombre, apellido, telefono } = req.body;
|
||||
|
||||
const updatedUser = await User.update(req.user.id, {
|
||||
nombre,
|
||||
apellido,
|
||||
telefono
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Perfil actualizado exitosamente',
|
||||
user: updatedUser
|
||||
});
|
||||
});
|
||||
|
||||
// Obtener direcciones del usuario
|
||||
exports.getAddresses = asyncHandler(async (req, res) => {
|
||||
const addresses = await User.getAddresses(req.user.id);
|
||||
res.json({ addresses });
|
||||
});
|
||||
|
||||
// Agregar dirección
|
||||
exports.addAddress = asyncHandler(async (req, res) => {
|
||||
const address = await User.addAddress(req.user.id, req.body);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Dirección agregada exitosamente',
|
||||
address
|
||||
});
|
||||
});
|
||||
237
backend/src/controllers/cartController.js
Normal file
237
backend/src/controllers/cartController.js
Normal file
@@ -0,0 +1,237 @@
|
||||
const db = require('../config/db');
|
||||
const { asyncHandler } = require('../utils/helpers');
|
||||
|
||||
// Obtener carrito del usuario
|
||||
exports.getCart = asyncHandler(async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Buscar carrito activo
|
||||
let cartResult = await db.query(
|
||||
`SELECT * FROM carts
|
||||
WHERE user_id = $1 AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
let cart = cartResult.rows[0];
|
||||
|
||||
// Si no existe, crear uno nuevo
|
||||
if (!cart) {
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24); // Expira en 24 horas
|
||||
|
||||
const newCartResult = await db.query(
|
||||
`INSERT INTO carts (user_id, expires_at) VALUES ($1, $2) RETURNING *`,
|
||||
[userId, expiresAt]
|
||||
);
|
||||
cart = newCartResult.rows[0];
|
||||
}
|
||||
|
||||
// Obtener items del carrito con información de productos
|
||||
const itemsResult = await db.query(
|
||||
`SELECT ci.*, p.nombre, p.precio_base, p.sku,
|
||||
(ci.cantidad * ci.precio_unitario) as subtotal,
|
||||
i.stock_actual, i.stock_reservado,
|
||||
(i.stock_actual - i.stock_reservado) as stock_disponible,
|
||||
(SELECT url FROM product_images WHERE product_id = p.id AND is_primary = true LIMIT 1) as imagen
|
||||
FROM cart_items ci
|
||||
JOIN products p ON ci.product_id = p.id
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
WHERE ci.cart_id = $1`,
|
||||
[cart.id]
|
||||
);
|
||||
|
||||
// Calcular total
|
||||
const total = itemsResult.rows.reduce((sum, item) => sum + parseFloat(item.subtotal), 0);
|
||||
|
||||
res.json({
|
||||
cart: {
|
||||
...cart,
|
||||
items: itemsResult.rows,
|
||||
total
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Agregar producto al carrito
|
||||
exports.addToCart = asyncHandler(async (req, res) => {
|
||||
const { product_id, cantidad } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Verificar que el producto existe y tiene stock
|
||||
const productResult = await db.query(
|
||||
`SELECT p.*, i.stock_actual, i.stock_reservado,
|
||||
(i.stock_actual - i.stock_reservado) as stock_disponible
|
||||
FROM products p
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
WHERE p.id = $1 AND p.is_active = true`,
|
||||
[product_id]
|
||||
);
|
||||
|
||||
const product = productResult.rows[0];
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
if (product.stock_disponible < cantidad) {
|
||||
return res.status(400).json({
|
||||
error: 'Stock insuficiente',
|
||||
disponible: product.stock_disponible
|
||||
});
|
||||
}
|
||||
|
||||
// Obtener o crear carrito
|
||||
let cartResult = await db.query(
|
||||
`SELECT * FROM carts
|
||||
WHERE user_id = $1 AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
let cart = cartResult.rows[0];
|
||||
|
||||
if (!cart) {
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
const newCartResult = await db.query(
|
||||
`INSERT INTO carts (user_id, expires_at) VALUES ($1, $2) RETURNING *`,
|
||||
[userId, expiresAt]
|
||||
);
|
||||
cart = newCartResult.rows[0];
|
||||
}
|
||||
|
||||
// Verificar si el producto ya está en el carrito
|
||||
const existingItemResult = await db.query(
|
||||
'SELECT * FROM cart_items WHERE cart_id = $1 AND product_id = $2',
|
||||
[cart.id, product_id]
|
||||
);
|
||||
|
||||
let item;
|
||||
|
||||
if (existingItemResult.rows.length > 0) {
|
||||
// Actualizar cantidad
|
||||
const nuevaCantidad = existingItemResult.rows[0].cantidad + cantidad;
|
||||
|
||||
if (product.stock_disponible < nuevaCantidad) {
|
||||
return res.status(400).json({
|
||||
error: 'Stock insuficiente para la cantidad solicitada',
|
||||
disponible: product.stock_disponible
|
||||
});
|
||||
}
|
||||
|
||||
const updateResult = await db.query(
|
||||
`UPDATE cart_items SET cantidad = $1 WHERE id = $2 RETURNING *`,
|
||||
[nuevaCantidad, existingItemResult.rows[0].id]
|
||||
);
|
||||
item = updateResult.rows[0];
|
||||
} else {
|
||||
// Agregar nuevo item
|
||||
const insertResult = await db.query(
|
||||
`INSERT INTO cart_items (cart_id, product_id, cantidad, precio_unitario)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[cart.id, product_id, cantidad, product.precio_base]
|
||||
);
|
||||
item = insertResult.rows[0];
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Producto agregado al carrito',
|
||||
item
|
||||
});
|
||||
});
|
||||
|
||||
// Actualizar cantidad de item en el carrito
|
||||
exports.updateCartItem = asyncHandler(async (req, res) => {
|
||||
const { cantidad } = req.body;
|
||||
const itemId = req.params.itemId;
|
||||
|
||||
// Verificar que el item pertenece al usuario
|
||||
const itemResult = await db.query(
|
||||
`SELECT ci.*, c.user_id, p.precio_base,
|
||||
i.stock_actual, i.stock_reservado,
|
||||
(i.stock_actual - i.stock_reservado) as stock_disponible
|
||||
FROM cart_items ci
|
||||
JOIN carts c ON ci.cart_id = c.id
|
||||
JOIN products p ON ci.product_id = p.id
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
WHERE ci.id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
|
||||
const item = itemResult.rows[0];
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Item no encontrado' });
|
||||
}
|
||||
|
||||
if (item.user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'No autorizado' });
|
||||
}
|
||||
|
||||
if (cantidad > item.stock_disponible) {
|
||||
return res.status(400).json({
|
||||
error: 'Stock insuficiente',
|
||||
disponible: item.stock_disponible
|
||||
});
|
||||
}
|
||||
|
||||
// Actualizar cantidad
|
||||
const updateResult = await db.query(
|
||||
`UPDATE cart_items SET cantidad = $1 WHERE id = $2 RETURNING *`,
|
||||
[cantidad, itemId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Cantidad actualizada',
|
||||
item: updateResult.rows[0]
|
||||
});
|
||||
});
|
||||
|
||||
// Eliminar item del carrito
|
||||
exports.removeFromCart = asyncHandler(async (req, res) => {
|
||||
const itemId = req.params.itemId;
|
||||
|
||||
// Verificar que el item pertenece al usuario
|
||||
const itemResult = await db.query(
|
||||
`SELECT ci.*, c.user_id FROM cart_items ci
|
||||
JOIN carts c ON ci.cart_id = c.id
|
||||
WHERE ci.id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
|
||||
const item = itemResult.rows[0];
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Item no encontrado' });
|
||||
}
|
||||
|
||||
if (item.user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'No autorizado' });
|
||||
}
|
||||
|
||||
// Eliminar item
|
||||
await db.query('DELETE FROM cart_items WHERE id = $1', [itemId]);
|
||||
|
||||
res.json({ message: 'Producto eliminado del carrito' });
|
||||
});
|
||||
|
||||
// Vaciar carrito
|
||||
exports.clearCart = asyncHandler(async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Obtener carrito activo
|
||||
const cartResult = await db.query(
|
||||
`SELECT id FROM carts
|
||||
WHERE user_id = $1 AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (cartResult.rows.length > 0) {
|
||||
await db.query('DELETE FROM cart_items WHERE cart_id = $1', [cartResult.rows[0].id]);
|
||||
}
|
||||
|
||||
res.json({ message: 'Carrito vaciado' });
|
||||
});
|
||||
85
backend/src/controllers/categoryController.js
Normal file
85
backend/src/controllers/categoryController.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const Category = require('../models/Category');
|
||||
const { asyncHandler } = require('../utils/helpers');
|
||||
|
||||
// Listar categorías
|
||||
exports.listCategories = asyncHandler(async (req, res) => {
|
||||
const { is_active, parent_id } = req.query;
|
||||
|
||||
const categories = await Category.list({
|
||||
is_active: is_active !== undefined ? is_active === 'true' : undefined,
|
||||
parent_id: parent_id ? parseInt(parent_id) : undefined
|
||||
});
|
||||
|
||||
res.json({ categories });
|
||||
});
|
||||
|
||||
// Obtener árbol de categorías
|
||||
exports.getCategoryTree = asyncHandler(async (req, res) => {
|
||||
const tree = await Category.getTree();
|
||||
res.json({ categories: tree });
|
||||
});
|
||||
|
||||
// Obtener categoría por ID
|
||||
exports.getCategory = asyncHandler(async (req, res) => {
|
||||
const category = await Category.findById(req.params.id);
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Categoría no encontrada' });
|
||||
}
|
||||
|
||||
res.json({ category });
|
||||
});
|
||||
|
||||
// Crear categoría (solo admin)
|
||||
exports.createCategory = asyncHandler(async (req, res) => {
|
||||
const category = await Category.create(req.body);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Categoría creada exitosamente',
|
||||
category
|
||||
});
|
||||
});
|
||||
|
||||
// Actualizar categoría (solo admin)
|
||||
exports.updateCategory = asyncHandler(async (req, res) => {
|
||||
const category = await Category.update(req.params.id, req.body);
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Categoría no encontrada' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Categoría actualizada exitosamente',
|
||||
category
|
||||
});
|
||||
});
|
||||
|
||||
// Eliminar categoría (solo admin)
|
||||
exports.deleteCategory = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
await Category.delete(req.params.id);
|
||||
res.json({ message: 'Categoría eliminada exitosamente' });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener productos de la categoría
|
||||
exports.getCategoryProducts = asyncHandler(async (req, res) => {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { products, total } = await Category.getProducts(
|
||||
req.params.id,
|
||||
{ limit: parseInt(limit), offset: parseInt(offset) }
|
||||
);
|
||||
|
||||
res.json({
|
||||
products,
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages: Math.ceil(total / limit),
|
||||
totalItems: total
|
||||
}
|
||||
});
|
||||
});
|
||||
73
backend/src/controllers/inventoryController.js
Normal file
73
backend/src/controllers/inventoryController.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const Inventory = require('../models/Inventory');
|
||||
const { asyncHandler } = require('../utils/helpers');
|
||||
|
||||
// Obtener inventario de un producto
|
||||
exports.getInventory = asyncHandler(async (req, res) => {
|
||||
const inventory = await Inventory.getByProductId(req.params.productId);
|
||||
|
||||
if (!inventory) {
|
||||
return res.status(404).json({ error: 'Inventario no encontrado' });
|
||||
}
|
||||
|
||||
res.json({ inventory });
|
||||
});
|
||||
|
||||
// Ajustar stock (solo admin)
|
||||
exports.adjustStock = asyncHandler(async (req, res) => {
|
||||
const { product_id, cantidad, tipo, notas } = req.body;
|
||||
|
||||
const result = await Inventory.adjustStock(
|
||||
product_id,
|
||||
cantidad,
|
||||
tipo,
|
||||
`Manual - User ${req.user.id}`,
|
||||
notas,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Stock ajustado exitosamente',
|
||||
...result
|
||||
});
|
||||
});
|
||||
|
||||
// Obtener movimientos de inventario
|
||||
exports.getMovements = asyncHandler(async (req, res) => {
|
||||
const { limit = 50, offset = 0 } = req.query;
|
||||
|
||||
const { movements, total } = await Inventory.getMovements(
|
||||
req.params.productId,
|
||||
{ limit: parseInt(limit), offset: parseInt(offset) }
|
||||
);
|
||||
|
||||
res.json({
|
||||
movements,
|
||||
total
|
||||
});
|
||||
});
|
||||
|
||||
// Obtener productos con stock bajo (solo admin)
|
||||
exports.getLowStock = asyncHandler(async (req, res) => {
|
||||
const { threshold } = req.query;
|
||||
|
||||
const products = await Inventory.getLowStock(
|
||||
threshold ? parseInt(threshold) : null
|
||||
);
|
||||
|
||||
res.json({ products });
|
||||
});
|
||||
|
||||
// Actualizar stock mínimo (solo admin)
|
||||
exports.updateMinStock = asyncHandler(async (req, res) => {
|
||||
const { stock_minimo } = req.body;
|
||||
|
||||
const inventory = await Inventory.updateMinStock(
|
||||
req.params.productId,
|
||||
stock_minimo
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Stock mínimo actualizado',
|
||||
inventory
|
||||
});
|
||||
});
|
||||
153
backend/src/controllers/orderController.js
Normal file
153
backend/src/controllers/orderController.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const Order = require('../models/Order');
|
||||
const { asyncHandler, getPaginationParams, formatPaginatedResponse } = require('../utils/helpers');
|
||||
|
||||
// Crear orden
|
||||
exports.createOrder = asyncHandler(async (req, res) => {
|
||||
const { items, address_id, notas_cliente, metodo_pago } = req.body;
|
||||
|
||||
// El channel_id por defecto es 'tienda' (id: 1)
|
||||
const order = await Order.create({
|
||||
user_id: req.user.id,
|
||||
channel_id: 1, // tienda
|
||||
items,
|
||||
address_id,
|
||||
notas_cliente
|
||||
});
|
||||
|
||||
// TODO: Iniciar proceso de pago según metodo_pago
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Orden creada exitosamente',
|
||||
order
|
||||
});
|
||||
});
|
||||
|
||||
// Obtener orden por ID
|
||||
exports.getOrder = asyncHandler(async (req, res) => {
|
||||
const order = await Order.findById(req.params.id);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
// Verificar que sea el dueño o admin
|
||||
if (order.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'No tienes permiso para ver esta orden' });
|
||||
}
|
||||
|
||||
// Obtener items
|
||||
const items = await Order.getItems(order.id);
|
||||
|
||||
// Obtener información de envío
|
||||
const shipment = await Order.getShipment(order.id);
|
||||
|
||||
res.json({
|
||||
order,
|
||||
items,
|
||||
shipment
|
||||
});
|
||||
});
|
||||
|
||||
// Buscar orden por número
|
||||
exports.getOrderByNumber = asyncHandler(async (req, res) => {
|
||||
const order = await Order.findByOrderNumber(req.params.orderNumber);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
// Verificar que sea el dueño o admin
|
||||
if (order.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'No tienes permiso para ver esta orden' });
|
||||
}
|
||||
|
||||
const items = await Order.getItems(order.id);
|
||||
const shipment = await Order.getShipment(order.id);
|
||||
|
||||
res.json({
|
||||
order,
|
||||
items,
|
||||
shipment
|
||||
});
|
||||
});
|
||||
|
||||
// Listar órdenes
|
||||
exports.listOrders = asyncHandler(async (req, res) => {
|
||||
const { page, limit, offset } = getPaginationParams(req.query.page, req.query.limit);
|
||||
const { estado, channel_id, fecha_desde, fecha_hasta } = req.query;
|
||||
|
||||
const filters = {
|
||||
estado,
|
||||
channel_id: channel_id ? parseInt(channel_id) : undefined,
|
||||
fecha_desde,
|
||||
fecha_hasta,
|
||||
limit,
|
||||
offset
|
||||
};
|
||||
|
||||
// Si no es admin, solo ver sus propias órdenes
|
||||
if (req.user.role !== 'admin') {
|
||||
filters.user_id = req.user.id;
|
||||
}
|
||||
|
||||
const { orders, total } = await Order.list(filters);
|
||||
|
||||
res.json(formatPaginatedResponse(orders, total, page, limit));
|
||||
});
|
||||
|
||||
// Actualizar estado de orden (solo admin)
|
||||
exports.updateOrderStatus = asyncHandler(async (req, res) => {
|
||||
const { estado } = req.body;
|
||||
|
||||
const order = await Order.updateStatus(req.params.id, estado);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Estado de orden actualizado',
|
||||
order
|
||||
});
|
||||
});
|
||||
|
||||
// Cancelar orden
|
||||
exports.cancelOrder = asyncHandler(async (req, res) => {
|
||||
const order = await Order.findById(req.params.id);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Orden no encontrada' });
|
||||
}
|
||||
|
||||
// Solo el dueño o admin pueden cancelar
|
||||
if (order.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'No tienes permiso para cancelar esta orden' });
|
||||
}
|
||||
|
||||
// Solo se puede cancelar si está en estado pendiente_pago
|
||||
if (order.estado !== 'pendiente_pago') {
|
||||
return res.status(400).json({
|
||||
error: 'Solo se pueden cancelar órdenes en estado pendiente de pago'
|
||||
});
|
||||
}
|
||||
|
||||
const cancelledOrder = await Order.cancel(req.params.id);
|
||||
|
||||
res.json({
|
||||
message: 'Orden cancelada exitosamente',
|
||||
order: cancelledOrder
|
||||
});
|
||||
});
|
||||
|
||||
// Mis órdenes (cliente)
|
||||
exports.getMyOrders = asyncHandler(async (req, res) => {
|
||||
const { page, limit, offset } = getPaginationParams(req.query.page, req.query.limit);
|
||||
|
||||
const { orders, total } = await Order.list({
|
||||
user_id: req.user.id,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
res.json(formatPaginatedResponse(orders, total, page, limit));
|
||||
});
|
||||
121
backend/src/controllers/productController.js
Normal file
121
backend/src/controllers/productController.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const Product = require('../models/Product');
|
||||
const { asyncHandler, getPaginationParams, formatPaginatedResponse } = require('../utils/helpers');
|
||||
|
||||
// Listar productos
|
||||
exports.listProducts = asyncHandler(async (req, res) => {
|
||||
const { page, limit, offset } = getPaginationParams(req.query.page, req.query.limit);
|
||||
const { search, category_id, is_active, min_price, max_price } = req.query;
|
||||
|
||||
const { products, total } = await Product.list({
|
||||
search,
|
||||
category_id: category_id ? parseInt(category_id) : undefined,
|
||||
is_active: is_active !== undefined ? is_active === 'true' : undefined,
|
||||
min_price: min_price ? parseFloat(min_price) : undefined,
|
||||
max_price: max_price ? parseFloat(max_price) : undefined,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
// Agregar imágenes a cada producto
|
||||
for (const product of products) {
|
||||
product.images = await Product.getImages(product.id);
|
||||
}
|
||||
|
||||
res.json(formatPaginatedResponse(products, total, page, limit));
|
||||
});
|
||||
|
||||
// Obtener producto por ID
|
||||
exports.getProduct = asyncHandler(async (req, res) => {
|
||||
const product = await Product.findById(req.params.id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
// Agregar imágenes
|
||||
product.images = await Product.getImages(product.id);
|
||||
|
||||
res.json({ product });
|
||||
});
|
||||
|
||||
// Crear producto (solo admin)
|
||||
exports.createProduct = asyncHandler(async (req, res) => {
|
||||
const product = await Product.create(req.body);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Producto creado exitosamente',
|
||||
product
|
||||
});
|
||||
});
|
||||
|
||||
// Actualizar producto (solo admin)
|
||||
exports.updateProduct = asyncHandler(async (req, res) => {
|
||||
const product = await Product.update(req.params.id, req.body);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Producto actualizado exitosamente',
|
||||
product
|
||||
});
|
||||
});
|
||||
|
||||
// Eliminar producto (solo admin)
|
||||
exports.deleteProduct = asyncHandler(async (req, res) => {
|
||||
const product = await Product.delete(req.params.id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Producto eliminado exitosamente' });
|
||||
});
|
||||
|
||||
// Agregar imagen al producto (solo admin)
|
||||
exports.addImage = asyncHandler(async (req, res) => {
|
||||
const productId = req.params.id;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No se proporcionó ninguna imagen' });
|
||||
}
|
||||
|
||||
const imageUrl = `/uploads/products/${req.file.filename}`;
|
||||
|
||||
const image = await Product.addImage(productId, {
|
||||
url: imageUrl,
|
||||
alt_text: req.body.alt_text || '',
|
||||
orden: req.body.orden ? parseInt(req.body.orden) : 0,
|
||||
is_primary: req.body.is_primary === 'true'
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Imagen agregada exitosamente',
|
||||
image
|
||||
});
|
||||
});
|
||||
|
||||
// Eliminar imagen (solo admin)
|
||||
exports.deleteImage = asyncHandler(async (req, res) => {
|
||||
const image = await Product.deleteImage(req.params.imageId);
|
||||
|
||||
if (!image) {
|
||||
return res.status(404).json({ error: 'Imagen no encontrada' });
|
||||
}
|
||||
|
||||
// TODO: Eliminar archivo físico del servidor
|
||||
|
||||
res.json({ message: 'Imagen eliminada exitosamente' });
|
||||
});
|
||||
|
||||
// Obtener stock del producto
|
||||
exports.getStock = asyncHandler(async (req, res) => {
|
||||
const stock = await Product.getStock(req.params.id);
|
||||
|
||||
if (!stock) {
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
res.json({ stock });
|
||||
});
|
||||
36
backend/src/controllers/userController.js
Normal file
36
backend/src/controllers/userController.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const db = require('../config/db');
|
||||
|
||||
exports.listUsers = async (req, res) => {
|
||||
if (process.env.DBTYPE === 'postgres') {
|
||||
try {
|
||||
const result = await db.query('SELECT id, nombre, correo FROM prueba ORDER BY id');
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
res.status(500).send('Error consultando tabla: ' + err.message);
|
||||
}
|
||||
} else {
|
||||
db.query('SELECT id, nombre, correo FROM prueba ORDER BY id', (err, results) => {
|
||||
if (err) res.status(500).send('Error consultando tabla: ' + err.message);
|
||||
else res.json(results);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.createUser = async (req, res) => {
|
||||
const { nombre, correo } = req.body;
|
||||
if (!nombre || !correo) return res.status(400).send('Faltan campos');
|
||||
|
||||
if (process.env.DBTYPE === 'postgres') {
|
||||
try {
|
||||
await db.query('INSERT INTO prueba (nombre, correo) VALUES ($1, $2)', [nombre, correo]);
|
||||
res.send(`Usuario ${nombre} agregado correctamente`);
|
||||
} catch (err) {
|
||||
res.status(500).send('Error insertando usuario: ' + err.message);
|
||||
}
|
||||
} else {
|
||||
db.query('INSERT INTO prueba (nombre, correo) VALUES (?, ?)', [nombre, correo], (err) => {
|
||||
if (err) res.status(500).send('Error insertando usuario: ' + err.message);
|
||||
else res.send(`Usuario ${nombre} agregado correctamente`);
|
||||
});
|
||||
}
|
||||
};
|
||||
40
backend/src/middlewares/auth.js
Normal file
40
backend/src/middlewares/auth.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const authMiddleware = (req, res, next) => {
|
||||
try {
|
||||
// Obtener token del header
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: 'No autorizado. Token no proporcionado.'
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
// Verificar token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Agregar datos del usuario al request
|
||||
req.user = {
|
||||
id: decoded.id,
|
||||
email: decoded.email,
|
||||
role: decoded.role
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: 'Token expirado. Por favor inicia sesión nuevamente.'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
error: 'Token inválido.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = authMiddleware;
|
||||
51
backend/src/middlewares/errorHandler.js
Normal file
51
backend/src/middlewares/errorHandler.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
// Error de validación de Joi
|
||||
if (err.isJoi) {
|
||||
return res.status(400).json({
|
||||
error: 'Error de validación',
|
||||
details: err.details.map(d => ({
|
||||
field: d.path.join('.'),
|
||||
message: d.message
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
// Error de Multer (archivos)
|
||||
if (err.name === 'MulterError') {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({
|
||||
error: 'El archivo es demasiado grande. Máximo 5MB.'
|
||||
});
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: 'Error al subir archivo: ' + err.message
|
||||
});
|
||||
}
|
||||
|
||||
// Error de PostgreSQL
|
||||
if (err.code && err.code.startsWith('23')) {
|
||||
if (err.code === '23505') { // Unique violation
|
||||
return res.status(409).json({
|
||||
error: 'El registro ya existe.',
|
||||
detail: err.detail
|
||||
});
|
||||
}
|
||||
if (err.code === '23503') { // Foreign key violation
|
||||
return res.status(400).json({
|
||||
error: 'Referencia inválida.',
|
||||
detail: err.detail
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Error por defecto
|
||||
const statusCode = err.statusCode || 500;
|
||||
res.status(statusCode).json({
|
||||
error: err.message || 'Error interno del servidor',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = errorHandler;
|
||||
23
backend/src/middlewares/roleCheck.js
Normal file
23
backend/src/middlewares/roleCheck.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const { ROLES } = require('../config/constants');
|
||||
|
||||
const roleCheck = (...allowedRoles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: 'No autenticado.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(req.user.role)) {
|
||||
return res.status(403).json({
|
||||
error: 'No tienes permisos para realizar esta acción.',
|
||||
requiredRoles: allowedRoles,
|
||||
yourRole: req.user.role
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = roleCheck;
|
||||
19
backend/src/middlewares/validation.js
Normal file
19
backend/src/middlewares/validation.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const validate = (schema) => {
|
||||
return (req, res, next) => {
|
||||
const { error } = schema.validate(req.body, {
|
||||
abortEarly: false,
|
||||
stripUnknown: true
|
||||
});
|
||||
|
||||
if (error) {
|
||||
error.isJoi = true;
|
||||
return next(error);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = validate;
|
||||
163
backend/src/models/Category.js
Normal file
163
backend/src/models/Category.js
Normal file
@@ -0,0 +1,163 @@
|
||||
const db = require('../config/db');
|
||||
|
||||
class Category {
|
||||
// Crear categoría
|
||||
static async create({ nombre, slug, descripcion, parent_id, orden = 0, codigo }) {
|
||||
// Si no se proporciona código, generar uno automáticamente
|
||||
const generatedCodigo = codigo || nombre.substring(0, 4).toUpperCase();
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO categories (nombre, slug, descripcion, parent_id, orden, codigo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[nombre, slug, descripcion, parent_id, orden, generatedCodigo]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Buscar por ID
|
||||
static async findById(id) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM categories WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Buscar por slug
|
||||
static async findBySlug(slug) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM categories WHERE slug = $1',
|
||||
[slug]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Listar todas las categorías
|
||||
static async list({ is_active, parent_id } = {}) {
|
||||
let query = 'SELECT * FROM categories WHERE 1=1';
|
||||
const params = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (is_active !== undefined) {
|
||||
query += ` AND is_active = $${paramCount}`;
|
||||
params.push(is_active);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (parent_id !== undefined) {
|
||||
if (parent_id === null) {
|
||||
query += ' AND parent_id IS NULL';
|
||||
} else {
|
||||
query += ` AND parent_id = $${paramCount}`;
|
||||
params.push(parent_id);
|
||||
paramCount++;
|
||||
}
|
||||
}
|
||||
|
||||
query += ' ORDER BY orden ASC, nombre ASC';
|
||||
|
||||
const result = await db.query(query, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Obtener árbol de categorías
|
||||
static async getTree() {
|
||||
const categories = await this.list();
|
||||
|
||||
const buildTree = (parentId = null) => {
|
||||
return categories
|
||||
.filter(cat => cat.parent_id === parentId)
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
children: buildTree(cat.id)
|
||||
}));
|
||||
};
|
||||
|
||||
return buildTree();
|
||||
}
|
||||
|
||||
// Actualizar categoría
|
||||
static async update(id, data) {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let counter = 1;
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (value !== undefined && key !== 'id') {
|
||||
fields.push(`${key} = $${counter}`);
|
||||
values.push(value);
|
||||
counter++;
|
||||
}
|
||||
});
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
values.push(id);
|
||||
const query = `
|
||||
UPDATE categories
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${counter}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await db.query(query, values);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Eliminar categoría
|
||||
static async delete(id) {
|
||||
// Verificar si tiene productos
|
||||
const productsCheck = await db.query(
|
||||
'SELECT COUNT(*) FROM products WHERE category_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (parseInt(productsCheck.rows[0].count) > 0) {
|
||||
throw new Error('No se puede eliminar una categoría con productos asociados');
|
||||
}
|
||||
|
||||
// Verificar si tiene subcategorías
|
||||
const subcatsCheck = await db.query(
|
||||
'SELECT COUNT(*) FROM categories WHERE parent_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (parseInt(subcatsCheck.rows[0].count) > 0) {
|
||||
throw new Error('No se puede eliminar una categoría con subcategorías');
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
'DELETE FROM categories WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Obtener productos de la categoría
|
||||
static async getProducts(categoryId, { limit = 20, offset = 0 } = {}) {
|
||||
const result = await db.query(
|
||||
`SELECT p.*, i.stock_actual, i.stock_reservado,
|
||||
(i.stock_actual - i.stock_reservado) as stock_disponible
|
||||
FROM products p
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
WHERE p.category_id = $1 AND p.is_active = true
|
||||
ORDER BY p.nombre ASC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[categoryId, limit, offset]
|
||||
);
|
||||
|
||||
const countResult = await db.query(
|
||||
'SELECT COUNT(*) FROM products WHERE category_id = $1 AND is_active = true',
|
||||
[categoryId]
|
||||
);
|
||||
|
||||
return {
|
||||
products: result.rows,
|
||||
total: parseInt(countResult.rows[0].count)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Category;
|
||||
263
backend/src/models/Inventory.js
Normal file
263
backend/src/models/Inventory.js
Normal file
@@ -0,0 +1,263 @@
|
||||
const db = require('../config/db');
|
||||
const { INVENTORY_MOVEMENT_TYPES } = require('../config/constants');
|
||||
|
||||
class Inventory {
|
||||
// Obtener inventario de un producto
|
||||
static async getByProductId(productId) {
|
||||
const result = await db.query(
|
||||
`SELECT i.*, p.nombre as product_nombre, p.sku
|
||||
FROM inventory i
|
||||
JOIN products p ON i.product_id = p.id
|
||||
WHERE i.product_id = $1`,
|
||||
[productId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Ajustar stock (entrada, salida, ajuste)
|
||||
static async adjustStock(productId, cantidad, tipo, referencia = null, notas = null, createdBy = null) {
|
||||
const client = await db.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Obtener stock actual
|
||||
const inventoryResult = await client.query(
|
||||
'SELECT stock_actual FROM inventory WHERE product_id = $1 FOR UPDATE',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (inventoryResult.rows.length === 0) {
|
||||
throw new Error('Producto no tiene registro de inventario');
|
||||
}
|
||||
|
||||
const stockAnterior = inventoryResult.rows[0].stock_actual;
|
||||
let stockNuevo;
|
||||
|
||||
// Calcular nuevo stock según tipo
|
||||
switch (tipo) {
|
||||
case INVENTORY_MOVEMENT_TYPES.ENTRADA:
|
||||
stockNuevo = stockAnterior + cantidad;
|
||||
break;
|
||||
case INVENTORY_MOVEMENT_TYPES.SALIDA:
|
||||
stockNuevo = stockAnterior - cantidad;
|
||||
if (stockNuevo < 0) {
|
||||
throw new Error('Stock insuficiente');
|
||||
}
|
||||
break;
|
||||
case INVENTORY_MOVEMENT_TYPES.AJUSTE:
|
||||
stockNuevo = cantidad; // En ajuste, la cantidad es el valor final
|
||||
break;
|
||||
default:
|
||||
throw new Error('Tipo de movimiento inválido');
|
||||
}
|
||||
|
||||
// Actualizar inventory
|
||||
await client.query(
|
||||
'UPDATE inventory SET stock_actual = $1 WHERE product_id = $2',
|
||||
[stockNuevo, productId]
|
||||
);
|
||||
|
||||
// Registrar movimiento
|
||||
const movementResult = await client.query(
|
||||
`INSERT INTO inventory_movements
|
||||
(product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva, referencia, notas, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[productId, tipo, cantidad, stockAnterior, stockNuevo, referencia, notas, createdBy]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return {
|
||||
inventory: { stock_actual: stockNuevo, stock_anterior: stockAnterior },
|
||||
movement: movementResult.rows[0]
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Reservar stock (para carrito/checkout)
|
||||
static async reserveStock(productId, cantidad) {
|
||||
const client = await db.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const inventoryResult = await client.query(
|
||||
'SELECT stock_actual, stock_reservado FROM inventory WHERE product_id = $1 FOR UPDATE',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (inventoryResult.rows.length === 0) {
|
||||
throw new Error('Producto no encontrado');
|
||||
}
|
||||
|
||||
const { stock_actual, stock_reservado } = inventoryResult.rows[0];
|
||||
const stockDisponible = stock_actual - stock_reservado;
|
||||
|
||||
if (stockDisponible < cantidad) {
|
||||
throw new Error(`Stock insuficiente. Disponible: ${stockDisponible}`);
|
||||
}
|
||||
|
||||
// Incrementar stock reservado
|
||||
await client.query(
|
||||
'UPDATE inventory SET stock_reservado = stock_reservado + $1 WHERE product_id = $2',
|
||||
[cantidad, productId]
|
||||
);
|
||||
|
||||
// Registrar movimiento
|
||||
await client.query(
|
||||
`INSERT INTO inventory_movements
|
||||
(product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva)
|
||||
VALUES ($1, 'reserva', $2, $3, $4)`,
|
||||
[productId, cantidad, stock_reservado, stock_reservado + cantidad]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Liberar stock reservado
|
||||
static async releaseStock(productId, cantidad) {
|
||||
const client = await db.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const inventoryResult = await client.query(
|
||||
'SELECT stock_reservado FROM inventory WHERE product_id = $1 FOR UPDATE',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (inventoryResult.rows.length === 0) {
|
||||
throw new Error('Producto no encontrado');
|
||||
}
|
||||
|
||||
const stock_reservado = inventoryResult.rows[0].stock_reservado;
|
||||
|
||||
// Disminuir stock reservado
|
||||
await client.query(
|
||||
'UPDATE inventory SET stock_reservado = GREATEST(stock_reservado - $1, 0) WHERE product_id = $2',
|
||||
[cantidad, productId]
|
||||
);
|
||||
|
||||
// Registrar movimiento
|
||||
await client.query(
|
||||
`INSERT INTO inventory_movements
|
||||
(product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva)
|
||||
VALUES ($1, 'liberacion', $2, $3, $4)`,
|
||||
[productId, cantidad, stock_reservado, Math.max(stock_reservado - cantidad, 0)]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmar venta (reducir stock actual y reservado)
|
||||
static async confirmSale(productId, cantidad, referencia = null) {
|
||||
const client = await db.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const inventoryResult = await client.query(
|
||||
'SELECT stock_actual, stock_reservado FROM inventory WHERE product_id = $1 FOR UPDATE',
|
||||
[productId]
|
||||
);
|
||||
|
||||
const { stock_actual, stock_reservado } = inventoryResult.rows[0];
|
||||
|
||||
// Reducir stock actual y reservado
|
||||
await client.query(
|
||||
`UPDATE inventory
|
||||
SET stock_actual = stock_actual - $1,
|
||||
stock_reservado = GREATEST(stock_reservado - $1, 0)
|
||||
WHERE product_id = $2`,
|
||||
[cantidad, productId]
|
||||
);
|
||||
|
||||
// Registrar movimiento
|
||||
await client.query(
|
||||
`INSERT INTO inventory_movements
|
||||
(product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva, referencia)
|
||||
VALUES ($1, 'salida', $2, $3, $4, $5)`,
|
||||
[productId, cantidad, stock_actual, stock_actual - cantidad, referencia]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener movimientos de inventario
|
||||
static async getMovements(productId, { limit = 50, offset = 0 } = {}) {
|
||||
const result = await db.query(
|
||||
`SELECT m.*, u.nombre as created_by_nombre
|
||||
FROM inventory_movements m
|
||||
LEFT JOIN users u ON m.created_by = u.id
|
||||
WHERE m.product_id = $1
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[productId, limit, offset]
|
||||
);
|
||||
|
||||
const countResult = await db.query(
|
||||
'SELECT COUNT(*) FROM inventory_movements WHERE product_id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
return {
|
||||
movements: result.rows,
|
||||
total: parseInt(countResult.rows[0].count)
|
||||
};
|
||||
}
|
||||
|
||||
// Listar productos con stock bajo
|
||||
static async getLowStock(threshold = null) {
|
||||
const query = threshold
|
||||
? 'SELECT * FROM inventory WHERE stock_actual <= $1 ORDER BY stock_actual ASC'
|
||||
: 'SELECT * FROM inventory WHERE stock_actual <= stock_minimo ORDER BY stock_actual ASC';
|
||||
|
||||
const params = threshold ? [threshold] : [];
|
||||
const result = await db.query(query, params);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Actualizar stock mínimo
|
||||
static async updateMinStock(productId, stockMinimo) {
|
||||
const result = await db.query(
|
||||
'UPDATE inventory SET stock_minimo = $1 WHERE product_id = $2 RETURNING *',
|
||||
[stockMinimo, productId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Inventory;
|
||||
266
backend/src/models/Order.js
Normal file
266
backend/src/models/Order.js
Normal file
@@ -0,0 +1,266 @@
|
||||
const db = require('../config/db');
|
||||
|
||||
class Order {
|
||||
// Crear orden
|
||||
static async create(orderData) {
|
||||
const {
|
||||
user_id, channel_id, items, address_id,
|
||||
notas_cliente, descuento = 0
|
||||
} = orderData;
|
||||
|
||||
const client = await db.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Generar número de orden
|
||||
const orderNumberResult = await client.query('SELECT generate_order_number() as number');
|
||||
const orderNumber = orderNumberResult.rows[0].number;
|
||||
|
||||
// Calcular subtotal
|
||||
let subtotal = 0;
|
||||
for (const item of items) {
|
||||
const productResult = await client.query(
|
||||
'SELECT precio_base FROM products WHERE id = $1',
|
||||
[item.product_id]
|
||||
);
|
||||
const precio = productResult.rows[0].precio_base;
|
||||
subtotal += precio * item.cantidad;
|
||||
}
|
||||
|
||||
// Por ahora, costo_envio = 0 (se calculará después con BlueExpress)
|
||||
const costo_envio = 0;
|
||||
const total = subtotal - descuento + costo_envio;
|
||||
|
||||
// Crear orden
|
||||
const orderResult = await client.query(
|
||||
`INSERT INTO orders (order_number, user_id, channel_id, estado, subtotal, descuento, costo_envio, total, notas_cliente)
|
||||
VALUES ($1, $2, $3, 'pendiente_pago', $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[orderNumber, user_id, channel_id, subtotal, descuento, costo_envio, total, notas_cliente]
|
||||
);
|
||||
|
||||
const order = orderResult.rows[0];
|
||||
|
||||
// Crear items de la orden
|
||||
for (const item of items) {
|
||||
const productResult = await client.query(
|
||||
'SELECT precio_base FROM products WHERE id = $1',
|
||||
[item.product_id]
|
||||
);
|
||||
const precio_unitario = productResult.rows[0].precio_base;
|
||||
const item_subtotal = precio_unitario * item.cantidad;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO order_items (order_id, product_id, cantidad, precio_unitario, subtotal)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[order.id, item.product_id, item.cantidad, precio_unitario, item_subtotal]
|
||||
);
|
||||
}
|
||||
|
||||
// Crear registro de envío
|
||||
if (address_id) {
|
||||
await client.query(
|
||||
`INSERT INTO shipments (order_id, direccion_id, estado)
|
||||
VALUES ($1, $2, 'pendiente')`,
|
||||
[order.id, address_id]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return order;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar orden por ID
|
||||
static async findById(orderId) {
|
||||
const result = await db.query(
|
||||
`SELECT o.*,
|
||||
u.nombre || ' ' || COALESCE(u.apellido, '') as cliente_nombre,
|
||||
u.email as cliente_email,
|
||||
ch.nombre as canal
|
||||
FROM orders o
|
||||
JOIN users u ON o.user_id = u.id
|
||||
JOIN channels ch ON o.channel_id = ch.id
|
||||
WHERE o.id = $1`,
|
||||
[orderId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Buscar por número de orden
|
||||
static async findByOrderNumber(orderNumber) {
|
||||
const result = await db.query(
|
||||
`SELECT o.*,
|
||||
u.nombre || ' ' || COALESCE(u.apellido, '') as cliente_nombre,
|
||||
u.email as cliente_email
|
||||
FROM orders o
|
||||
JOIN users u ON o.user_id = u.id
|
||||
WHERE o.order_number = $1`,
|
||||
[orderNumber]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Obtener items de la orden
|
||||
static async getItems(orderId) {
|
||||
const result = await db.query(
|
||||
`SELECT oi.*, p.nombre as producto_nombre, p.sku
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = $1`,
|
||||
[orderId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Listar órdenes con filtros
|
||||
static async list({
|
||||
user_id, estado, channel_id,
|
||||
fecha_desde, fecha_hasta,
|
||||
limit = 20, offset = 0
|
||||
} = {}) {
|
||||
let query = `
|
||||
SELECT o.*,
|
||||
u.nombre || ' ' || COALESCE(u.apellido, '') as cliente_nombre,
|
||||
ch.nombre as canal,
|
||||
p.estado as estado_pago
|
||||
FROM orders o
|
||||
JOIN users u ON o.user_id = u.id
|
||||
JOIN channels ch ON o.channel_id = ch.id
|
||||
LEFT JOIN payments p ON o.id = p.order_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (user_id) {
|
||||
query += ` AND o.user_id = $${paramCount}`;
|
||||
params.push(user_id);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (estado) {
|
||||
query += ` AND o.estado = $${paramCount}`;
|
||||
params.push(estado);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (channel_id) {
|
||||
query += ` AND o.channel_id = $${paramCount}`;
|
||||
params.push(channel_id);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (fecha_desde) {
|
||||
query += ` AND o.created_at >= $${paramCount}`;
|
||||
params.push(fecha_desde);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (fecha_hasta) {
|
||||
query += ` AND o.created_at <= $${paramCount}`;
|
||||
params.push(fecha_hasta);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
query += ` ORDER BY o.created_at DESC LIMIT $${paramCount} OFFSET $${paramCount + 1}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await db.query(query, params);
|
||||
|
||||
// Contar total
|
||||
const countParams = params.slice(0, -2);
|
||||
let countQuery = 'SELECT COUNT(*) FROM orders o WHERE 1=1';
|
||||
if (user_id) countQuery += ' AND o.user_id = $1';
|
||||
if (estado) countQuery += ` AND o.estado = $${user_id ? 2 : 1}`;
|
||||
|
||||
const countResult = await db.query(countQuery, countParams);
|
||||
|
||||
return {
|
||||
orders: result.rows,
|
||||
total: parseInt(countResult.rows[0].count)
|
||||
};
|
||||
}
|
||||
|
||||
// Actualizar estado de la orden
|
||||
static async updateStatus(orderId, nuevoEstado) {
|
||||
const result = await db.query(
|
||||
'UPDATE orders SET estado = $1 WHERE id = $2 RETURNING *',
|
||||
[nuevoEstado, orderId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Actualizar costo de envío
|
||||
static async updateShippingCost(orderId, costoEnvio) {
|
||||
const result = await db.query(
|
||||
`UPDATE orders
|
||||
SET costo_envio = $1,
|
||||
total = subtotal - descuento + $1
|
||||
WHERE id = $2
|
||||
RETURNING *`,
|
||||
[costoEnvio, orderId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Cancelar orden
|
||||
static async cancel(orderId) {
|
||||
const client = await db.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Obtener items para liberar stock
|
||||
const itemsResult = await client.query(
|
||||
'SELECT product_id, cantidad FROM order_items WHERE order_id = $1',
|
||||
[orderId]
|
||||
);
|
||||
|
||||
// Liberar stock reservado
|
||||
for (const item of itemsResult.rows) {
|
||||
await client.query(
|
||||
'UPDATE inventory SET stock_reservado = GREATEST(stock_reservado - $1, 0) WHERE product_id = $2',
|
||||
[item.cantidad, item.product_id]
|
||||
);
|
||||
}
|
||||
|
||||
// Actualizar estado
|
||||
const orderResult = await client.query(
|
||||
'UPDATE orders SET estado = \'cancelada\' WHERE id = $1 RETURNING *',
|
||||
[orderId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return orderResult.rows[0];
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener despacho de la orden
|
||||
static async getShipment(orderId) {
|
||||
const result = await db.query(
|
||||
`SELECT s.*, a.direccion, a.comuna, a.ciudad, a.region
|
||||
FROM shipments s
|
||||
LEFT JOIN user_addresses a ON s.direccion_id = a.id
|
||||
WHERE s.order_id = $1`,
|
||||
[orderId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Order;
|
||||
283
backend/src/models/Product.js
Normal file
283
backend/src/models/Product.js
Normal file
@@ -0,0 +1,283 @@
|
||||
const db = require('../config/db');
|
||||
|
||||
class Product {
|
||||
// Crear producto
|
||||
static async create(productData) {
|
||||
const {
|
||||
sku, nombre, descripcion, precio_base, peso_gramos,
|
||||
alto_cm, ancho_cm, largo_cm, category_id, stock_inicial = 0
|
||||
} = productData;
|
||||
|
||||
const client = await db.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Crear producto
|
||||
const productResult = await client.query(
|
||||
`INSERT INTO products (sku, nombre, descripcion, precio_base, peso_gramos, alto_cm, ancho_cm, largo_cm, category_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[sku, nombre, descripcion, precio_base, peso_gramos, alto_cm, ancho_cm, largo_cm, category_id]
|
||||
);
|
||||
|
||||
const product = productResult.rows[0];
|
||||
|
||||
// Crear registro de inventario
|
||||
await client.query(
|
||||
`INSERT INTO inventory (product_id, stock_actual)
|
||||
VALUES ($1, $2)`,
|
||||
[product.id, stock_inicial]
|
||||
);
|
||||
|
||||
// Registrar movimiento inicial si hay stock
|
||||
if (stock_inicial > 0) {
|
||||
await client.query(
|
||||
`INSERT INTO inventory_movements (product_id, tipo, cantidad, cantidad_anterior, cantidad_nueva, referencia)
|
||||
VALUES ($1, 'entrada', $2, 0, $2, 'Stock inicial')`,
|
||||
[product.id, stock_inicial]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return product;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar por ID con stock
|
||||
static async findById(id) {
|
||||
// Obtener producto con stock
|
||||
const result = await db.query(
|
||||
`SELECT p.*, i.stock_actual, i.stock_reservado, i.stock_minimo,
|
||||
(i.stock_actual - i.stock_reservado) as stock_disponible,
|
||||
c.nombre as categoria_nombre
|
||||
FROM products p
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const product = result.rows[0];
|
||||
|
||||
// Cargar imágenes
|
||||
const imagesResult = await db.query(
|
||||
`SELECT * FROM product_images
|
||||
WHERE product_id = $1
|
||||
ORDER BY is_primary DESC, orden ASC`,
|
||||
[id]
|
||||
);
|
||||
|
||||
product.images = imagesResult.rows;
|
||||
return product;
|
||||
}
|
||||
|
||||
// Buscar por SKU
|
||||
static async findBySku(sku) {
|
||||
const result = await db.query(
|
||||
`SELECT p.*, i.stock_actual, i.stock_reservado, i.stock_minimo,
|
||||
(i.stock_actual - i.stock_reservado) as stock_disponible
|
||||
FROM products p
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
WHERE p.sku = $1`,
|
||||
[sku]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Listar productos con filtros y búsqueda
|
||||
static async list({
|
||||
search, category_id, is_active,
|
||||
min_price, max_price,
|
||||
limit = 20, offset = 0
|
||||
} = {}) {
|
||||
let query = `
|
||||
SELECT p.*, i.stock_actual, i.stock_reservado,
|
||||
(i.stock_actual - i.stock_reservado) as stock_disponible,
|
||||
c.nombre as categoria_nombre
|
||||
FROM products p
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (search) {
|
||||
query += ` AND (p.nombre ILIKE $${paramCount} OR p.descripcion ILIKE $${paramCount} OR p.sku ILIKE $${paramCount})`;
|
||||
params.push(`%${search}%`);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (category_id) {
|
||||
query += ` AND p.category_id = $${paramCount}`;
|
||||
params.push(category_id);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (is_active !== undefined) {
|
||||
query += ` AND p.is_active = $${paramCount}`;
|
||||
params.push(is_active);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (min_price) {
|
||||
query += ` AND p.precio_base >= $${paramCount}`;
|
||||
params.push(min_price);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (max_price) {
|
||||
query += ` AND p.precio_base <= $${paramCount}`;
|
||||
params.push(max_price);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
query += ` ORDER BY p.created_at DESC LIMIT $${paramCount} OFFSET $${paramCount + 1}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await db.query(query, params);
|
||||
|
||||
// Cargar imágenes para cada producto
|
||||
const productIds = result.rows.map(p => p.id);
|
||||
let imagesMap = {};
|
||||
|
||||
if (productIds.length > 0) {
|
||||
const imagesResult = await db.query(
|
||||
`SELECT * FROM product_images
|
||||
WHERE product_id = ANY($1::int[])
|
||||
ORDER BY is_primary DESC, orden ASC`,
|
||||
[productIds]
|
||||
);
|
||||
|
||||
// Agrupar imágenes por product_id
|
||||
imagesResult.rows.forEach(img => {
|
||||
if (!imagesMap[img.product_id]) {
|
||||
imagesMap[img.product_id] = [];
|
||||
}
|
||||
imagesMap[img.product_id].push(img);
|
||||
});
|
||||
}
|
||||
|
||||
// Agregar imágenes a cada producto
|
||||
const products = result.rows.map(p => ({
|
||||
...p,
|
||||
images: imagesMap[p.id] || []
|
||||
}));
|
||||
|
||||
// Contar total
|
||||
let countQuery = 'SELECT COUNT(*) FROM products p WHERE 1=1';
|
||||
const countParams = params.slice(0, -2);
|
||||
|
||||
if (search) countQuery += ' AND (p.nombre ILIKE $1 OR p.descripcion ILIKE $1 OR p.sku ILIKE $1)';
|
||||
if (category_id) countQuery += ` AND p.category_id = $${search ? 2 : 1}`;
|
||||
if (is_active !== undefined) countQuery += ` AND p.is_active = $${countParams.length + 1}`;
|
||||
|
||||
const countResult = await db.query(countQuery, countParams);
|
||||
|
||||
return {
|
||||
products: products,
|
||||
total: parseInt(countResult.rows[0].count)
|
||||
};
|
||||
}
|
||||
|
||||
// Actualizar producto
|
||||
static async update(id, data) {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let counter = 1;
|
||||
|
||||
const allowedFields = [
|
||||
'nombre', 'descripcion', 'precio_base', 'peso_gramos',
|
||||
'alto_cm', 'ancho_cm', 'largo_cm', 'category_id', 'is_active'
|
||||
];
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (value !== undefined && allowedFields.includes(key)) {
|
||||
fields.push(`${key} = $${counter}`);
|
||||
values.push(value);
|
||||
counter++;
|
||||
}
|
||||
});
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
values.push(id);
|
||||
const query = `
|
||||
UPDATE products
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${counter}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await db.query(query, values);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Eliminar producto (soft delete)
|
||||
static async delete(id) {
|
||||
const result = await db.query(
|
||||
'UPDATE products SET is_active = false WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Obtener imágenes del producto
|
||||
static async getImages(productId) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM product_images WHERE product_id = $1 ORDER BY is_primary DESC, orden ASC',
|
||||
[productId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Agregar imagen
|
||||
static async addImage(productId, { url, alt_text, orden = 0, is_primary = false }) {
|
||||
// Si es imagen principal, quitar el flag de las demás
|
||||
if (is_primary) {
|
||||
await db.query(
|
||||
'UPDATE product_images SET is_primary = false WHERE product_id = $1',
|
||||
[productId]
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO product_images (product_id, url, alt_text, orden, is_primary)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[productId, url, alt_text, orden, is_primary]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Eliminar imagen
|
||||
static async deleteImage(imageId) {
|
||||
const result = await db.query(
|
||||
'DELETE FROM product_images WHERE id = $1 RETURNING *',
|
||||
[imageId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Obtener stock
|
||||
static async getStock(productId) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM inventory WHERE product_id = $1',
|
||||
[productId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Product;
|
||||
147
backend/src/models/User.js
Normal file
147
backend/src/models/User.js
Normal file
@@ -0,0 +1,147 @@
|
||||
const db = require('../config/db');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
class User {
|
||||
// Crear usuario
|
||||
static async create({ email, password, nombre, apellido, telefono, role = 'cliente' }) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO users (email, password_hash, nombre, apellido, telefono, role)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, email, nombre, apellido, telefono, role, is_active, created_at`,
|
||||
[email, passwordHash, nombre, apellido, telefono, role]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Buscar por email
|
||||
static async findByEmail(email) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Buscar por ID
|
||||
static async findById(id) {
|
||||
const result = await db.query(
|
||||
'SELECT id, email, nombre, apellido, telefono, role, is_active, created_at FROM users WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Verificar contraseña
|
||||
static async verifyPassword(plainPassword, hashedPassword) {
|
||||
return await bcrypt.compare(plainPassword, hashedPassword);
|
||||
}
|
||||
|
||||
// Actualizar usuario
|
||||
static async update(id, data) {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let counter = 1;
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (value !== undefined && key !== 'password_hash' && key !== 'id') {
|
||||
fields.push(`${key} = $${counter}`);
|
||||
values.push(value);
|
||||
counter++;
|
||||
}
|
||||
});
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
values.push(id);
|
||||
const query = `
|
||||
UPDATE users
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${counter}
|
||||
RETURNING id, email, nombre, apellido, telefono, role, is_active, updated_at
|
||||
`;
|
||||
|
||||
const result = await db.query(query, values);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Listar usuarios con filtros
|
||||
static async list({ role, is_active, limit = 20, offset = 0 }) {
|
||||
let query = 'SELECT id, email, nombre, apellido, telefono, role, is_active, created_at FROM users WHERE 1=1';
|
||||
const params = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (role) {
|
||||
query += ` AND role = $${paramCount}`;
|
||||
params.push(role);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (is_active !== undefined) {
|
||||
query += ` AND is_active = $${paramCount}`;
|
||||
params.push(is_active);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC LIMIT $${paramCount} OFFSET $${paramCount + 1}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await db.query(query, params);
|
||||
|
||||
// Contar total
|
||||
const countResult = await db.query('SELECT COUNT(*) FROM users WHERE 1=1' +
|
||||
(role ? ' AND role = $1' : '') +
|
||||
(is_active !== undefined ? ` AND is_active = $${role ? 2 : 1}` : ''),
|
||||
params.slice(0, -2)
|
||||
);
|
||||
|
||||
return {
|
||||
users: result.rows,
|
||||
total: parseInt(countResult.rows[0].count)
|
||||
};
|
||||
}
|
||||
|
||||
// Eliminar usuario (soft delete)
|
||||
static async delete(id) {
|
||||
const result = await db.query(
|
||||
'UPDATE users SET is_active = false WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Direcciones del usuario
|
||||
static async getAddresses(userId) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM user_addresses WHERE user_id = $1 ORDER BY is_default DESC, created_at DESC',
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Agregar dirección
|
||||
static async addAddress(userId, addressData) {
|
||||
const { direccion, comuna, ciudad, region, codigo_postal, referencia, is_default } = addressData;
|
||||
|
||||
// Si es dirección por defecto, quitar el default de las demás
|
||||
if (is_default) {
|
||||
await db.query(
|
||||
'UPDATE user_addresses SET is_default = false WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO user_addresses (user_id, direccion, comuna, ciudad, region, codigo_postal, referencia, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[userId, direccion, comuna, ciudad, region, codigo_postal, referencia, is_default]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = User;
|
||||
18
backend/src/routes/authRoutes.js
Normal file
18
backend/src/routes/authRoutes.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const authController = require('../controllers/authController');
|
||||
const authMiddleware = require('../middlewares/auth');
|
||||
const validate = require('../middlewares/validation');
|
||||
const { registerSchema, loginSchema, createAddressSchema } = require('../utils/validators');
|
||||
|
||||
// Rutas públicas
|
||||
router.post('/register', validate(registerSchema), authController.register);
|
||||
router.post('/login', validate(loginSchema), authController.login);
|
||||
|
||||
// Rutas protegidas
|
||||
router.get('/profile', authMiddleware, authController.getProfile);
|
||||
router.put('/profile', authMiddleware, authController.updateProfile);
|
||||
router.get('/addresses', authMiddleware, authController.getAddresses);
|
||||
router.post('/addresses', authMiddleware, validate(createAddressSchema), authController.addAddress);
|
||||
|
||||
module.exports = router;
|
||||
17
backend/src/routes/cartRoutes.js
Normal file
17
backend/src/routes/cartRoutes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const cartController = require('../controllers/cartController');
|
||||
const authMiddleware = require('../middlewares/auth');
|
||||
const validate = require('../middlewares/validation');
|
||||
const { addToCartSchema, updateCartItemSchema } = require('../utils/validators');
|
||||
|
||||
// Todas las rutas requieren autenticación
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', cartController.getCart);
|
||||
router.post('/items', validate(addToCartSchema), cartController.addToCart);
|
||||
router.put('/items/:itemId', validate(updateCartItemSchema), cartController.updateCartItem);
|
||||
router.delete('/items/:itemId', cartController.removeFromCart);
|
||||
router.delete('/clear', cartController.clearCart);
|
||||
|
||||
module.exports = router;
|
||||
35
backend/src/routes/categoryRoutes.js
Normal file
35
backend/src/routes/categoryRoutes.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const categoryController = require('../controllers/categoryController');
|
||||
const authMiddleware = require('../middlewares/auth');
|
||||
const roleCheck = require('../middlewares/roleCheck');
|
||||
const validate = require('../middlewares/validation');
|
||||
const { createCategorySchema } = require('../utils/validators');
|
||||
|
||||
// Rutas públicas
|
||||
router.get('/', categoryController.listCategories);
|
||||
router.get('/tree', categoryController.getCategoryTree);
|
||||
router.get('/:id', categoryController.getCategory);
|
||||
router.get('/:id/products', categoryController.getCategoryProducts);
|
||||
|
||||
// Rutas protegidas (solo admin)
|
||||
router.post('/',
|
||||
authMiddleware,
|
||||
roleCheck('admin'),
|
||||
validate(createCategorySchema),
|
||||
categoryController.createCategory
|
||||
);
|
||||
|
||||
router.put('/:id',
|
||||
authMiddleware,
|
||||
roleCheck('admin'),
|
||||
categoryController.updateCategory
|
||||
);
|
||||
|
||||
router.delete('/:id',
|
||||
authMiddleware,
|
||||
roleCheck('admin'),
|
||||
categoryController.deleteCategory
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
19
backend/src/routes/inventoryRoutes.js
Normal file
19
backend/src/routes/inventoryRoutes.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const inventoryController = require('../controllers/inventoryController');
|
||||
const authMiddleware = require('../middlewares/auth');
|
||||
const roleCheck = require('../middlewares/roleCheck');
|
||||
const validate = require('../middlewares/validation');
|
||||
const { adjustStockSchema } = require('../utils/validators');
|
||||
|
||||
// Todas las rutas requieren autenticación de admin
|
||||
router.use(authMiddleware);
|
||||
router.use(roleCheck('admin'));
|
||||
|
||||
router.get('/product/:productId', inventoryController.getInventory);
|
||||
router.post('/adjust', validate(adjustStockSchema), inventoryController.adjustStock);
|
||||
router.get('/product/:productId/movements', inventoryController.getMovements);
|
||||
router.get('/low-stock', inventoryController.getLowStock);
|
||||
router.put('/product/:productId/min-stock', inventoryController.updateMinStock);
|
||||
|
||||
module.exports = router;
|
||||
23
backend/src/routes/orderRoutes.js
Normal file
23
backend/src/routes/orderRoutes.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const orderController = require('../controllers/orderController');
|
||||
const authMiddleware = require('../middlewares/auth');
|
||||
const roleCheck = require('../middlewares/roleCheck');
|
||||
const validate = require('../middlewares/validation');
|
||||
const { createOrderSchema } = require('../utils/validators');
|
||||
|
||||
// Todas las rutas requieren autenticación
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Rutas de cliente
|
||||
router.get('/my-orders', orderController.getMyOrders);
|
||||
router.post('/', validate(createOrderSchema), orderController.createOrder);
|
||||
router.get('/:id', orderController.getOrder);
|
||||
router.get('/number/:orderNumber', orderController.getOrderByNumber);
|
||||
router.post('/:id/cancel', orderController.cancelOrder);
|
||||
|
||||
// Rutas de admin
|
||||
router.get('/', roleCheck('admin'), orderController.listOrders);
|
||||
router.put('/:id/status', roleCheck('admin'), orderController.updateOrderStatus);
|
||||
|
||||
module.exports = router;
|
||||
50
backend/src/routes/productRoutes.js
Normal file
50
backend/src/routes/productRoutes.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const productController = require('../controllers/productController');
|
||||
const authMiddleware = require('../middlewares/auth');
|
||||
const roleCheck = require('../middlewares/roleCheck');
|
||||
const validate = require('../middlewares/validation');
|
||||
const upload = require('../config/multer');
|
||||
const { createProductSchema, updateProductSchema } = require('../utils/validators');
|
||||
|
||||
// Rutas públicas
|
||||
router.get('/', productController.listProducts);
|
||||
router.get('/:id', productController.getProduct);
|
||||
router.get('/:id/stock', productController.getStock);
|
||||
|
||||
// Rutas protegidas (solo admin)
|
||||
router.post('/',
|
||||
authMiddleware,
|
||||
roleCheck('admin'),
|
||||
validate(createProductSchema),
|
||||
productController.createProduct
|
||||
);
|
||||
|
||||
router.put('/:id',
|
||||
authMiddleware,
|
||||
roleCheck('admin'),
|
||||
validate(updateProductSchema),
|
||||
productController.updateProduct
|
||||
);
|
||||
|
||||
router.delete('/:id',
|
||||
authMiddleware,
|
||||
roleCheck('admin'),
|
||||
productController.deleteProduct
|
||||
);
|
||||
|
||||
// Imágenes
|
||||
router.post('/:id/images',
|
||||
authMiddleware,
|
||||
roleCheck('admin'),
|
||||
upload.single('image'),
|
||||
productController.addImage
|
||||
);
|
||||
|
||||
router.delete('/:id/images/:imageId',
|
||||
authMiddleware,
|
||||
roleCheck('admin'),
|
||||
productController.deleteImage
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
130
backend/src/routes/uploadRoutes.js
Normal file
130
backend/src/routes/uploadRoutes.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../config/db');
|
||||
|
||||
// Configuración de multer para subir imágenes
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../public/uploads/products');
|
||||
|
||||
// Crear directorio si no existe
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB máximo
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Solo se permiten imágenes (jpeg, jpg, png, gif, webp)'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/upload/product/:productId - Subir imagen de producto
|
||||
router.post('/product/:productId', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { productId } = req.params;
|
||||
const { alt_text, is_primary } = req.body;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No se recibió ninguna imagen' });
|
||||
}
|
||||
|
||||
// Verificar que el producto existe
|
||||
const productCheck = await db.query('SELECT id FROM products WHERE id = $1', [productId]);
|
||||
if (productCheck.rows.length === 0) {
|
||||
// Eliminar archivo subido
|
||||
fs.unlinkSync(req.file.path);
|
||||
return res.status(404).json({ error: 'Producto no encontrado' });
|
||||
}
|
||||
|
||||
// Si es imagen principal, marcar las demás como no principales
|
||||
if (is_primary === 'true' || is_primary === true) {
|
||||
await db.query(
|
||||
'UPDATE product_images SET is_primary = false WHERE product_id = $1',
|
||||
[productId]
|
||||
);
|
||||
}
|
||||
|
||||
// Obtener el siguiente orden
|
||||
const ordenResult = await db.query(
|
||||
'SELECT COALESCE(MAX(orden), -1) + 1 as next_orden FROM product_images WHERE product_id = $1',
|
||||
[productId]
|
||||
);
|
||||
const nextOrden = ordenResult.rows[0].next_orden;
|
||||
|
||||
// Guardar en la base de datos
|
||||
const imageUrl = `/uploads/products/${req.file.filename}`;
|
||||
const result = await db.query(
|
||||
`INSERT INTO product_images (product_id, url, alt_text, orden, is_primary)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[productId, imageUrl, alt_text || req.file.originalname, nextOrden, is_primary === 'true' || is_primary === true]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Imagen subida exitosamente',
|
||||
image: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error al subir imagen:', error);
|
||||
|
||||
// Eliminar archivo si hubo error
|
||||
if (req.file) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Error al subir imagen' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/upload/image/:imageId - Eliminar imagen
|
||||
router.delete('/image/:imageId', async (req, res) => {
|
||||
try {
|
||||
const { imageId } = req.params;
|
||||
|
||||
// Obtener información de la imagen
|
||||
const result = await db.query('SELECT * FROM product_images WHERE id = $1', [imageId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Imagen no encontrada' });
|
||||
}
|
||||
|
||||
const image = result.rows[0];
|
||||
const filePath = path.join(__dirname, '../public', image.url);
|
||||
|
||||
// Eliminar de la base de datos
|
||||
await db.query('DELETE FROM product_images WHERE id = $1', [imageId]);
|
||||
|
||||
// Eliminar archivo físico
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
res.json({ message: 'Imagen eliminada exitosamente' });
|
||||
} catch (error) {
|
||||
console.error('Error al eliminar imagen:', error);
|
||||
res.status(500).json({ error: 'Error al eliminar imagen' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
8
backend/src/routes/userRoutes.js
Normal file
8
backend/src/routes/userRoutes.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const userController = require('../controllers/userController');
|
||||
|
||||
router.get('/', userController.listUsers);
|
||||
router.post('/', userController.createUser);
|
||||
|
||||
module.exports = router;
|
||||
139
backend/src/utils/helpers.js
Normal file
139
backend/src/utils/helpers.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// Formatear precio a formato chileno
|
||||
const formatPrice = (price) => {
|
||||
return new Intl.NumberFormat('es-CL', {
|
||||
style: 'currency',
|
||||
currency: 'CLP'
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
// Generar slug desde texto
|
||||
const generateSlug = (text) => {
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[áàäâ]/g, 'a')
|
||||
.replace(/[éèëê]/g, 'e')
|
||||
.replace(/[íìïî]/g, 'i')
|
||||
.replace(/[óòöô]/g, 'o')
|
||||
.replace(/[úùüû]/g, 'u')
|
||||
.replace(/ñ/g, 'n')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
};
|
||||
|
||||
// Calcular precio con factor (ej: +10% para MercadoLibre)
|
||||
const calculateAdjustedPrice = (basePrice, factor = 1.0) => {
|
||||
return Math.round(basePrice * factor);
|
||||
};
|
||||
|
||||
// Formatear fecha a formato chileno
|
||||
const formatDate = (date) => {
|
||||
return new Intl.DateTimeFormat('es-CL', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
// Generar código aleatorio
|
||||
const generateCode = (length = 8) => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Calcular peso total de productos
|
||||
const calculateTotalWeight = (items) => {
|
||||
return items.reduce((total, item) => {
|
||||
const weight = item.peso_gramos || 0;
|
||||
const quantity = item.cantidad || 1;
|
||||
return total + (weight * quantity);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Paginación helper
|
||||
const getPaginationParams = (page = 1, limit = 20) => {
|
||||
const pageNum = parseInt(page) || 1;
|
||||
const limitNum = Math.min(parseInt(limit) || 20, 100);
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
|
||||
return {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
offset
|
||||
};
|
||||
};
|
||||
|
||||
// Formatear respuesta paginada
|
||||
const formatPaginatedResponse = (data, total, page, limit) => {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPages,
|
||||
totalItems: total,
|
||||
itemsPerPage: limit,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Sanitizar datos de usuario (remover password)
|
||||
const sanitizeUser = (user) => {
|
||||
const { password_hash, ...sanitized } = user;
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
// Validar RUT chileno
|
||||
const validateRUT = (rut) => {
|
||||
if (!rut || typeof rut !== 'string') return false;
|
||||
|
||||
const cleanRut = rut.replace(/[.-]/g, '');
|
||||
const rutDigits = cleanRut.slice(0, -1);
|
||||
const verifier = cleanRut.slice(-1).toUpperCase();
|
||||
|
||||
let sum = 0;
|
||||
let multiplier = 2;
|
||||
|
||||
for (let i = rutDigits.length - 1; i >= 0; i--) {
|
||||
sum += parseInt(rutDigits[i]) * multiplier;
|
||||
multiplier = multiplier === 7 ? 2 : multiplier + 1;
|
||||
}
|
||||
|
||||
const expectedVerifier = 11 - (sum % 11);
|
||||
const calculatedVerifier = expectedVerifier === 11 ? '0' : expectedVerifier === 10 ? 'K' : expectedVerifier.toString();
|
||||
|
||||
return verifier === calculatedVerifier;
|
||||
};
|
||||
|
||||
// Delay helper (para rate limiting)
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// Manejo de errores async
|
||||
const asyncHandler = (fn) => {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
formatPrice,
|
||||
generateSlug,
|
||||
calculateAdjustedPrice,
|
||||
formatDate,
|
||||
generateCode,
|
||||
calculateTotalWeight,
|
||||
getPaginationParams,
|
||||
formatPaginatedResponse,
|
||||
sanitizeUser,
|
||||
validateRUT,
|
||||
delay,
|
||||
asyncHandler
|
||||
};
|
||||
40
backend/src/utils/logger.js
Normal file
40
backend/src/utils/logger.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
|
||||
// Configurar formato
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
// Crear logger
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
defaultMeta: { service: 'ofertaweb-api' },
|
||||
transports: [
|
||||
// Escribir logs de error a archivo
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../logs/error.log'),
|
||||
level: 'error'
|
||||
}),
|
||||
// Escribir todos los logs a archivo
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../logs/combined.log')
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// En desarrollo, también mostrar en consola
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = logger;
|
||||
131
backend/src/utils/validators.js
Normal file
131
backend/src/utils/validators.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
// Validador de email
|
||||
const emailSchema = Joi.string().email().required();
|
||||
|
||||
// Validador de password (mínimo 6 caracteres)
|
||||
const passwordSchema = Joi.string().min(6).required();
|
||||
|
||||
// Validador de SKU (alfanumérico con guiones)
|
||||
const skuSchema = Joi.string().pattern(/^[A-Za-z0-9-]+$/).required();
|
||||
|
||||
// Validador de precio
|
||||
const priceSchema = Joi.number().positive().precision(2).required();
|
||||
|
||||
// Validador de cantidad
|
||||
const quantitySchema = Joi.number().integer().positive().required();
|
||||
|
||||
// Validador de paginación
|
||||
const paginationSchema = Joi.object({
|
||||
page: Joi.number().integer().min(1).default(1),
|
||||
limit: Joi.number().integer().min(1).max(100).default(20)
|
||||
});
|
||||
|
||||
// Schemas de validación completos
|
||||
|
||||
// Auth
|
||||
const registerSchema = Joi.object({
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
nombre: Joi.string().min(2).max(100).required(),
|
||||
apellido: Joi.string().max(100).optional(),
|
||||
telefono: Joi.string().pattern(/^[0-9+\-\s()]+$/).optional()
|
||||
});
|
||||
|
||||
const loginSchema = Joi.object({
|
||||
email: emailSchema,
|
||||
password: Joi.string().required()
|
||||
});
|
||||
|
||||
// Productos
|
||||
const createProductSchema = Joi.object({
|
||||
sku: skuSchema,
|
||||
nombre: Joi.string().min(3).max(255).required(),
|
||||
descripcion: Joi.string().allow('').optional(),
|
||||
precio_base: priceSchema,
|
||||
peso_gramos: Joi.number().integer().positive().optional(),
|
||||
alto_cm: Joi.number().positive().optional(),
|
||||
ancho_cm: Joi.number().positive().optional(),
|
||||
largo_cm: Joi.number().positive().optional(),
|
||||
category_id: Joi.number().integer().positive().optional(),
|
||||
stock_inicial: Joi.number().integer().min(0).default(0)
|
||||
});
|
||||
|
||||
const updateProductSchema = Joi.object({
|
||||
nombre: Joi.string().min(3).max(255).optional(),
|
||||
descripcion: Joi.string().allow('').optional(),
|
||||
precio_base: Joi.number().positive().precision(2).optional(),
|
||||
peso_gramos: Joi.number().integer().positive().optional(),
|
||||
alto_cm: Joi.number().positive().optional(),
|
||||
ancho_cm: Joi.number().positive().optional(),
|
||||
largo_cm: Joi.number().positive().optional(),
|
||||
category_id: Joi.number().integer().positive().allow(null).optional(),
|
||||
is_active: Joi.boolean().optional()
|
||||
});
|
||||
|
||||
// Categorías
|
||||
const createCategorySchema = Joi.object({
|
||||
nombre: Joi.string().min(2).max(100).required(),
|
||||
slug: Joi.string().pattern(/^[a-z0-9-]+$/).required(),
|
||||
descripcion: Joi.string().allow('').optional(),
|
||||
parent_id: Joi.number().integer().positive().allow(null).optional(),
|
||||
orden: Joi.number().integer().default(0)
|
||||
});
|
||||
|
||||
// Carrito
|
||||
const addToCartSchema = Joi.object({
|
||||
product_id: Joi.number().integer().positive().required(),
|
||||
cantidad: quantitySchema
|
||||
});
|
||||
|
||||
const updateCartItemSchema = Joi.object({
|
||||
cantidad: quantitySchema
|
||||
});
|
||||
|
||||
// Dirección
|
||||
const createAddressSchema = Joi.object({
|
||||
direccion: Joi.string().required(),
|
||||
comuna: Joi.string().required(),
|
||||
ciudad: Joi.string().required(),
|
||||
region: Joi.string().required(),
|
||||
codigo_postal: Joi.string().optional(),
|
||||
referencia: Joi.string().allow('').optional(),
|
||||
is_default: Joi.boolean().default(false)
|
||||
});
|
||||
|
||||
// Orden
|
||||
const createOrderSchema = Joi.object({
|
||||
address_id: Joi.number().integer().positive().required(),
|
||||
notas_cliente: Joi.string().allow('').optional(),
|
||||
metodo_pago: Joi.string().valid('webpay', 'transferencia', 'mercadopago', 'efectivo').required()
|
||||
});
|
||||
|
||||
// Inventario
|
||||
const adjustStockSchema = Joi.object({
|
||||
product_id: Joi.number().integer().positive().required(),
|
||||
cantidad: Joi.number().integer().required(),
|
||||
tipo: Joi.string().valid('entrada', 'salida', 'ajuste').required(),
|
||||
notas: Joi.string().allow('').optional()
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
// Componentes
|
||||
emailSchema,
|
||||
passwordSchema,
|
||||
skuSchema,
|
||||
priceSchema,
|
||||
quantitySchema,
|
||||
paginationSchema,
|
||||
|
||||
// Schemas completos
|
||||
registerSchema,
|
||||
loginSchema,
|
||||
createProductSchema,
|
||||
updateProductSchema,
|
||||
createCategorySchema,
|
||||
addToCartSchema,
|
||||
updateCartItemSchema,
|
||||
createAddressSchema,
|
||||
createOrderSchema,
|
||||
adjustStockSchema
|
||||
};
|
||||
5
frontend/.config
Normal file
5
frontend/.config
Normal file
@@ -0,0 +1,5 @@
|
||||
DOMAIN=ofertaweb.cl
|
||||
FRONTEND_TYPE=react-vite
|
||||
BACKEND_TYPE=express
|
||||
API_URL=https://api.ofertaweb.cl
|
||||
INSTALL_DATE=Tue Dec 9 04:17:26 CET 2025
|
||||
1
frontend/.env
Normal file
1
frontend/.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=https://api.example.com
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
114
frontend/COMANDOS.md
Normal file
114
frontend/COMANDOS.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 🛠️ Comandos Útiles
|
||||
|
||||
## Desarrollo
|
||||
|
||||
```bash
|
||||
# Iniciar servidor de desarrollo
|
||||
npm run dev
|
||||
|
||||
# Iniciar en puerto específico
|
||||
npm run dev -- --port 3000
|
||||
|
||||
# Abrir automáticamente en navegador
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build de producción
|
||||
npm run build
|
||||
|
||||
# Preview del build
|
||||
npm run preview
|
||||
|
||||
# Build con análisis de bundle
|
||||
npm run build -- --mode production
|
||||
```
|
||||
|
||||
## Dependencias
|
||||
|
||||
```bash
|
||||
# Instalar todas las dependencias
|
||||
npm install
|
||||
|
||||
# Agregar nueva dependencia
|
||||
npm install nombre-paquete
|
||||
|
||||
# Agregar dependencia de desarrollo
|
||||
npm install -D nombre-paquete
|
||||
|
||||
# Actualizar dependencias
|
||||
npm update
|
||||
|
||||
# Auditar vulnerabilidades
|
||||
npm audit
|
||||
npm audit fix
|
||||
```
|
||||
|
||||
## Linting y formato
|
||||
|
||||
```bash
|
||||
# Ejecutar linter
|
||||
npm run lint
|
||||
|
||||
# Fix automático de problemas
|
||||
npm run lint -- --fix
|
||||
```
|
||||
|
||||
## Testing (si está configurado)
|
||||
|
||||
```bash
|
||||
# Ejecutar tests
|
||||
npm test
|
||||
|
||||
# Tests en modo watch
|
||||
npm test -- --watch
|
||||
|
||||
# Coverage
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
```bash
|
||||
# Regenerar clases de Tailwind
|
||||
npx tailwindcss -i ./src/index.css -o ./dist/output.css --watch
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
# Limpiar caché de npm
|
||||
npm cache clean --force
|
||||
|
||||
# Eliminar node_modules y reinstalar
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
# Verificar versión de Node
|
||||
node --version
|
||||
npm --version
|
||||
```
|
||||
|
||||
## Despliegue
|
||||
|
||||
### Vercel
|
||||
```bash
|
||||
npm install -g vercel
|
||||
vercel
|
||||
```
|
||||
|
||||
### Netlify
|
||||
```bash
|
||||
npm install -g netlify-cli
|
||||
netlify deploy --prod
|
||||
```
|
||||
|
||||
### Build manual para servidor
|
||||
```bash
|
||||
npm run build
|
||||
# Subir carpeta dist/ al servidor
|
||||
scp -r dist/* usuario@servidor:/var/www/dominio/
|
||||
```
|
||||
|
||||
110
frontend/README-LOCAL.md
Normal file
110
frontend/README-LOCAL.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Desarrollo Local - Frontend
|
||||
|
||||
## 🚀 Inicio Rápido
|
||||
|
||||
### React + Vite
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
npm install
|
||||
|
||||
# Configurar variables de entorno
|
||||
cp .env .env.local
|
||||
nano .env.local # Ajustar API_URL a tu backend local
|
||||
|
||||
# Iniciar servidor de desarrollo
|
||||
npm run dev
|
||||
|
||||
# Acceder a: http://localhost:5173
|
||||
```
|
||||
|
||||
### Producción local
|
||||
|
||||
```bash
|
||||
# Build para producción
|
||||
npm run build
|
||||
|
||||
# Preview del build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 📝 Configuración
|
||||
|
||||
### Variables de entorno (.env.local)
|
||||
|
||||
```env
|
||||
# Backend local
|
||||
VITE_API_URL=http://localhost:3000
|
||||
|
||||
# O backend en servidor
|
||||
VITE_API_URL=https://api.tudominio.cl
|
||||
```
|
||||
|
||||
## 🔧 Comandos útiles
|
||||
|
||||
```bash
|
||||
npm install # Instalar dependencias
|
||||
npm run dev # Desarrollo (hot reload)
|
||||
npm run build # Build de producción
|
||||
npm run preview # Preview del build
|
||||
npm run lint # Linter
|
||||
```
|
||||
|
||||
## 📦 Estructura del proyecto
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # Componentes reutilizables
|
||||
│ │ ├── common/ # Componentes comunes
|
||||
│ │ └── layout/ # Layout (Header, Footer)
|
||||
│ ├── pages/ # Páginas (Home, About, NotFound)
|
||||
│ ├── services/ # API clients (axios)
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ ├── utils/ # Utilidades
|
||||
│ ├── assets/ # Imágenes, iconos
|
||||
│ ├── App.jsx # Componente principal
|
||||
│ └── main.jsx # Entry point
|
||||
├── public/ # Archivos estáticos
|
||||
├── index.html # HTML principal
|
||||
├── package.json # Dependencias
|
||||
├── vite.config.js # Configuración Vite
|
||||
└── tailwind.config.js # Configuración Tailwind
|
||||
```
|
||||
|
||||
## 🎨 Tailwind CSS
|
||||
|
||||
Este proyecto usa Tailwind CSS v3. Los estilos se configuran en:
|
||||
|
||||
- `tailwind.config.js` - Configuración principal
|
||||
- `src/index.css` - Directivas de Tailwind
|
||||
|
||||
## 🔌 Integración con Backend
|
||||
|
||||
El proyecto está configurado para conectarse con:
|
||||
- Express backend en `https://api.tudominio.cl`
|
||||
- PHP backend en `https://api.tudominio.cl`
|
||||
|
||||
Para desarrollo local, ajusta `VITE_API_URL` en `.env.local`
|
||||
|
||||
## 📱 Responsive
|
||||
|
||||
El diseño es completamente responsive usando Tailwind CSS:
|
||||
- Mobile first
|
||||
- Breakpoints: sm, md, lg, xl, 2xl
|
||||
|
||||
## 🚀 Deploy
|
||||
|
||||
El proyecto se construye con:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Los archivos generados en `dist/` están listos para servir con:
|
||||
- Nginx
|
||||
- Apache
|
||||
- Vercel
|
||||
- Netlify
|
||||
- GitHub Pages
|
||||
|
||||
16
frontend/README-TEMPLATE.md
Normal file
16
frontend/README-TEMPLATE.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
8
frontend/deploy.sh
Normal file
8
frontend/deploy.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
echo "Building React app..."
|
||||
npm run build
|
||||
echo "Setting permissions..."
|
||||
sudo chown -R www-data:www-data dist/
|
||||
echo "Reloading Nginx..."
|
||||
sudo systemctl reload nginx
|
||||
echo "✓ Deploy complete!"
|
||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4077
frontend/package-lock.json
generated
Normal file
4077
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "ofertaweb-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend e-commerce para OfertaWeb.cl",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
26
frontend/src/App.jsx
Normal file
26
frontend/src/App.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-4">
|
||||
OfertaWeb.cl
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Frontend en construcción
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
frontend/src/components/layout/Layout.css
Normal file
1
frontend/src/components/layout/Layout.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Layout styles handled by Tailwind */
|
||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user