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:
2025-12-09 00:35:46 -03:00
commit 2a88b4a71b
106 changed files with 22508 additions and 0 deletions

849
PLANIFICACION.md Normal file
View 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
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3001/api

View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3001/api

24
administracion/.gitignore vendored Normal file
View 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
View 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.

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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

View 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;
}

View 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;

View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>,
)

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;
}
};

View 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'
}
)
);

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View 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
View 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
View File

@@ -0,0 +1,3 @@
node_modules/
.env
logs/

1
backend/.port Normal file
View File

@@ -0,0 +1 @@
3006

229
backend/README.md Normal file
View 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

View 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;

View 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';

View 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';

View 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

File diff suppressed because it is too large Load Diff

41
backend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1 @@
# Este archivo mantiene el directorio en git

View 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 $$;

View 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
View 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}`);
});

View 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
View 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;

View 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;

View 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
});
});

View 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' });
});

View 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
}
});
});

View 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
});
});

View 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));
});

View 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 });
});

View 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`);
});
}
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
};

View 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;

View 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
View 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
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3001/api

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=https://api.example.com

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View 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
View 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
View 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;

View 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

View File

@@ -0,0 +1 @@
/* Layout styles handled by Tailwind */

3
frontend/src/index.css Normal file
View 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