Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
64
.gitignore
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# ============================================================
|
||||||
|
# SKEEN-Proyecto — .gitignore
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Entornos virtuales
|
||||||
|
venv/
|
||||||
|
*.venv
|
||||||
|
hermes-venv/
|
||||||
|
odoo-venv/
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Builds y caches
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
.cache/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# IDEs y editores
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Sistema
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Secretos y entorno local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.crt
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
*.der
|
||||||
|
*.cer
|
||||||
|
*.p7b
|
||||||
|
*.p7r
|
||||||
|
*.spc
|
||||||
|
|
||||||
|
# Backups y dumps
|
||||||
|
*.dump
|
||||||
|
*.sql
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Datos sensibles de migracion
|
||||||
|
datos/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
435
README.md
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
# SKEEN Derma Experts — Sistema Integral de Gestión Clínica
|
||||||
|
|
||||||
|
**Cliente:** SKEEN Derma Experts
|
||||||
|
**Contrato:** $182,500 MXN — 8 semanas (29 junio – 24 agosto 2026)
|
||||||
|
**Repositorio:** https://git.consultoria-as.com/consultoria-as/SKEEN-Proyecto
|
||||||
|
**Servidor actual:** Hetzner VPS (22 GB RAM / 6 vCPU / 77 GB SSD)
|
||||||
|
**Fecha de documentación:** 16 de julio de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabla de Contenidos
|
||||||
|
|
||||||
|
1. [Visión General](#visión-general)
|
||||||
|
2. [Arquitectura del Sistema](#arquitectura-del-sistema)
|
||||||
|
3. [Componentes del Proyecto](#componentes-del-proyecto)
|
||||||
|
4. [Estado Actual](#estado-actual)
|
||||||
|
5. [Inicio Rápido](#inicio-rápido)
|
||||||
|
6. [URLs y Puertos](#urls-y-puertos)
|
||||||
|
7. [Documentación Detallada](#documentación-detallada)
|
||||||
|
8. [Datos del Cliente Pendientes](#datos-del-cliente-pendientes)
|
||||||
|
9. [Comandos de Operación](#comandos-de-operación)
|
||||||
|
10. [Seguridad](#seguridad)
|
||||||
|
11. [Contribución](#contribución)
|
||||||
|
12. [Licencia](#licencia)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visión General
|
||||||
|
|
||||||
|
Este proyecto implementa un sistema integral de gestión clínica para **SKEEN Derma Experts**, una clínica dermatológica ubicada en Playas de Rosarito, BC, México. El sistema sustituye al software legacy (`https://sistema.skeenmx.app/`) y unifica:
|
||||||
|
|
||||||
|
- **WhatsApp Business** como canal principal de comunicación con pacientes.
|
||||||
|
- **IA conversacional** (Hermes + modelo Qwen3.6 de Nan Builders) para atención automatizada.
|
||||||
|
- **Odoo 17** como ERP/CRM central (pacientes, citas, ventas, pagos, monedero, inventario).
|
||||||
|
- **WACRM** como inbox compartido, pipelines Kanban y automatizaciones no-code.
|
||||||
|
- **Frontend React** como panel de control unificado para recepción, médicos y administración.
|
||||||
|
- **Supabase self-hosted** como backend de datos para WACRM y futuras integraciones.
|
||||||
|
|
||||||
|
### Objetivos del Proyecto
|
||||||
|
|
||||||
|
| Objetivo | Métrica |
|
||||||
|
|----------|---------|
|
||||||
|
| Reducir no-shows | Recordatorios automáticos 24h antes |
|
||||||
|
| Aumentar re-agendamiento | Re-engagement de pacientes inactivos (90 días) |
|
||||||
|
| Ahorrar tiempo de recepción | 10–15 horas/semana en agendamiento manual |
|
||||||
|
| Mejorar experiencia del paciente | Respuesta inmediata 24/7 vía Sofía |
|
||||||
|
| Centralizar información | Un solo sistema para citas, ventas, pagos e inventario |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura del Sistema
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────────┐ ┌──────────────────┐
|
||||||
|
│ WhatsApp │──────│ Meta Cloud API │──────│ WACRM │
|
||||||
|
│ (Meta) │ │ (Oficial) │ │ (Next.js 16) │
|
||||||
|
└─────────────┘ └─────────────────┘ │ Inbox + CRM │
|
||||||
|
│ Pipelines │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────┼────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────────┐ ┌──────────────────┐
|
||||||
|
│ Hermes │ │ Bridge │ │ Supabase │
|
||||||
|
│ Gateway │◄───────│ Webhook │ │ (PostgreSQL) │
|
||||||
|
│ (IA) │ │ (Node.js) │ │ + Auth + RLS │
|
||||||
|
└──────┬──────┘ └─────────────────┘ └────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────┐ ┌──────────────────┐
|
||||||
|
│ Nan Builders │ │ Odoo 17 │
|
||||||
|
│ (Qwen3.6) │ │ (ERP/CRM) │
|
||||||
|
└─────────────┘ │ Citas, Ventas, │
|
||||||
|
│ Pagos, Monedero │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Frontend │
|
||||||
|
│ React 18 │
|
||||||
|
│ (Vite + │
|
||||||
|
│ Tailwind) │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flujo de Datos Principal
|
||||||
|
|
||||||
|
1. **Paciente escribe por WhatsApp** → Meta Cloud API → WACRM.
|
||||||
|
2. **WACRM** registra contacto, conversación y mensaje; dispara automatización.
|
||||||
|
3. **Automatización** envía webhook al **Bridge SKEEN** (`:8090`).
|
||||||
|
4. **Bridge** consulta a **Nan Builders** (Qwen3.6) con el contexto de Sofía.
|
||||||
|
5. **Respuesta generada** se envía de vuelta a WACRM → WhatsApp.
|
||||||
|
6. **Sofía** puede consultar Odoo vía API para:
|
||||||
|
- Catálogo de servicios (`skeen-rag`).
|
||||||
|
- Agendar citas (`skeen-agendar`).
|
||||||
|
- Consultar saldo/monedero (`skeen-monedero`).
|
||||||
|
- Procesar pagos (`skeen-pagos`).
|
||||||
|
7. **Frontend React** muestra citas, pacientes, ventas, mensajes WACRM y leads en tiempo real.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componentes del Proyecto
|
||||||
|
|
||||||
|
### 1. Frontend React (`frontend/`)
|
||||||
|
|
||||||
|
**Tecnologías:** React 18, Vite, TypeScript, Tailwind CSS, React Router, Recharts, Axios.
|
||||||
|
|
||||||
|
**Rol:** Panel de control unificado para recepción, médicos y administración.
|
||||||
|
|
||||||
|
**Secciones:**
|
||||||
|
- Dashboard (stats, citas del día, ventas).
|
||||||
|
- Agenda / Calendario (crear, editar, cancelar citas).
|
||||||
|
- Pacientes (búsqueda, expediente, historial).
|
||||||
|
- Servicios (catálogo con precios y paquetes).
|
||||||
|
- Ventas / Corte de caja.
|
||||||
|
- Pagos / Adeudos.
|
||||||
|
- Monedero digital (puntos de fidelidad).
|
||||||
|
- Inventario (productos y consumibles).
|
||||||
|
- WACRM Mensajes (conversaciones de WhatsApp).
|
||||||
|
- WACRM Leads (pipelines Kanban).
|
||||||
|
- Reportes y analytics.
|
||||||
|
|
||||||
|
**Build:** `npm run build` → desplegado en `/var/www/skeen-frontend` vía nginx.
|
||||||
|
|
||||||
|
### 2. Frontend Homenest (`frontend-homenest/`)
|
||||||
|
|
||||||
|
**Tecnologías:** React 18, Vite, TypeScript, Tailwind CSS.
|
||||||
|
|
||||||
|
**Rol:** Versión alternativa del frontend con diseño "Homenest" (basado en la guía de marca de designmd.ai). No está en uso actualmente; se mantiene como referencia.
|
||||||
|
|
||||||
|
### 3. Módulos Odoo Custom (`odoo-addons/`)
|
||||||
|
|
||||||
|
Odoo 17 Community con módulos personalizados para SKEEN:
|
||||||
|
|
||||||
|
| Módulo | Propósito |
|
||||||
|
|--------|-----------|
|
||||||
|
| `skeen_citas` | Gestión de citas médicas, disponibilidad, paquetes de sesiones. |
|
||||||
|
| `skeen_pacientes` | Extensión de `res.partner` para pacientes, expedientes médicos. |
|
||||||
|
| `skeen_monedero` | Sistema de puntos/fidelidad (1 punto = $1 MXN por cada $10 gastados). |
|
||||||
|
| `skeen_pagos` | Pagos, adeudos, integración futura Stripe/MercadoPago. |
|
||||||
|
| `skeen_ventas` | Ventas, corte de caja, comisiones, metas. |
|
||||||
|
| `skeen_inventario` | Inventario de productos y consumibles. |
|
||||||
|
| `skeen_whatsapp` | API REST para Hermes/WACRM y Frontend React. |
|
||||||
|
|
||||||
|
**Base de datos:** PostgreSQL (`skeen_odoo`)
|
||||||
|
**Puerto:** `8069`
|
||||||
|
**Usuario admin:** `admin` / contraseña almacenada en `odoo.conf`
|
||||||
|
|
||||||
|
### 4. WACRM (`wacrm/`)
|
||||||
|
|
||||||
|
**Fork:** https://github.com/ArnasDon/wacrm (MIT)
|
||||||
|
**Rol:** Inbox compartido de WhatsApp, CRM, pipelines, automatizaciones no-code.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- Shared inbox multi-agente.
|
||||||
|
- Contactos, tags, campos personalizados.
|
||||||
|
- Pipelines Kanban (Lead → Consulta → Tratamiento → Recurrente).
|
||||||
|
- Automatizaciones visuales.
|
||||||
|
- Broadcasts con templates Meta.
|
||||||
|
- API REST pública (`/api/v1/*`).
|
||||||
|
- Webhooks outbound firmados con HMAC-SHA256.
|
||||||
|
|
||||||
|
**Puerto:** `3000`
|
||||||
|
**Backend:** Supabase (PostgreSQL + Auth + Realtime)
|
||||||
|
|
||||||
|
### 5. Hermes + Bridge (`hermes/`)
|
||||||
|
|
||||||
|
**Hermes:** Agente IA autónomo de Nous Research.
|
||||||
|
**Modelo:** Qwen3.6 via `https://api.nan.builders/v1`
|
||||||
|
**Bridge:** Servidor Node.js (`webhook-bridge.js`) que conecta WACRM con la API de Nan Builders.
|
||||||
|
|
||||||
|
**Skills disponibles:**
|
||||||
|
- `skeen-rag`: Catálogo de servicios, precios, paquetes.
|
||||||
|
- `skeen-agendar`: Agendamiento de citas en Odoo.
|
||||||
|
- `skeen-pagos`: Consulta de saldo y generación de links de pago.
|
||||||
|
- `skeen-monedero`: Consulta y redención de puntos.
|
||||||
|
- `skeen-recordatorios`: Recordatorios automáticos (heartbeat cada 30 min).
|
||||||
|
|
||||||
|
**Puertos:**
|
||||||
|
- Hermes Gateway: `8080` (API)
|
||||||
|
- Hermes Serve: `9119` (Dashboard)
|
||||||
|
- Bridge Webhook: `8090`
|
||||||
|
|
||||||
|
### 6. Migración (`migracion/`)
|
||||||
|
|
||||||
|
Scripts para extraer datos del sistema legacy e importarlos a Odoo:
|
||||||
|
|
||||||
|
- `importar_a_odoo.py`: Importación principal de pacientes, servicios, citas, ventas, pagos.
|
||||||
|
- `importar_servicios_inventario.py`: Importación de catálogo de servicios e inventario desde CSV.
|
||||||
|
- `extraer_skeen.py`: Extracción de datos del sistema legacy (requiere autorización).
|
||||||
|
- `ejecutar_migracion.sh`: Script wrapper de ejecución.
|
||||||
|
|
||||||
|
### 7. Scripts (`scripts/`)
|
||||||
|
|
||||||
|
- `skeen-start-all.sh`: Inicia todo el ecosistema.
|
||||||
|
- `skeen-stop-all.sh`: Detiene todo el ecosistema.
|
||||||
|
- `skeen-start-odoo.sh`: Inicia solo Odoo.
|
||||||
|
- `odoo-test-data.py`: Pruebas de datos en Odoo.
|
||||||
|
- `simular_meta_webhook.py`: Simulador de webhook de Meta para testing.
|
||||||
|
|
||||||
|
### 8. Infraestructura (`infra/`)
|
||||||
|
|
||||||
|
- `odoo.conf`: Configuración de Odoo.
|
||||||
|
- `docker-compose.dev.yml`: Desarrollo local (roadmap-hermes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estado Actual
|
||||||
|
|
||||||
|
### ✅ Completado
|
||||||
|
|
||||||
|
| Área | Estado |
|
||||||
|
|------|--------|
|
||||||
|
| Supabase self-hosted | Funcionando (Docker healthy) |
|
||||||
|
| WACRM | Funcionando (build producción) |
|
||||||
|
| Odoo 17 + módulos custom | Funcionando |
|
||||||
|
| Frontend React "SKEEN Brand" | Funcionando (nginx) |
|
||||||
|
| Hermes Gateway + Bridge | Funcionando |
|
||||||
|
| Integración WACRM ↔ Hermes | Funcionando (flujo end-to-end) |
|
||||||
|
| Integración Odoo ↔ Frontend | Funcionando |
|
||||||
|
| Integración WACRM ↔ Odoo | Funcionando (proxy/cache) |
|
||||||
|
| Catálogo de servicios con paquetes | Funcionando |
|
||||||
|
| Agendamiento normal y paquetes | Funcionando |
|
||||||
|
| Script de importación CSV | Funcionando |
|
||||||
|
| Documentación | En progreso |
|
||||||
|
|
||||||
|
### ⚠️ Pendiente
|
||||||
|
|
||||||
|
| Área | Bloqueo |
|
||||||
|
|------|---------|
|
||||||
|
| Número WhatsApp Business real | Cliente debe verificar en Meta |
|
||||||
|
| Meta App ID / Secret / Token | Cliente debe crear app en Meta Developers |
|
||||||
|
| Templates de WhatsApp aprobados | Subir y esperar aprobación de Meta |
|
||||||
|
| Lista de servicios real | Cliente debe enviar CSV |
|
||||||
|
| Stripe / MercadoPago | Cliente debe entregar keys |
|
||||||
|
| Dominio skeen.mx | DNS no apuntado aún |
|
||||||
|
| SSL / HTTPS | Configurar con Let's Encrypt |
|
||||||
|
| Backups automatizados | Configurar cron |
|
||||||
|
| Monitoreo Uptime Kuma | Configurar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inicio Rápido
|
||||||
|
|
||||||
|
### Requisitos
|
||||||
|
|
||||||
|
- Ubuntu 22.04+
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Node.js 20+
|
||||||
|
- Python 3.12+
|
||||||
|
- PostgreSQL 16
|
||||||
|
- nginx
|
||||||
|
|
||||||
|
### Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clonar el repositorio
|
||||||
|
git clone https://git.consultoria-as.com/consultoria-as/SKEEN-Proyecto.git
|
||||||
|
cd SKEEN-Proyecto
|
||||||
|
|
||||||
|
# Instalar dependencias del frontend
|
||||||
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
|
# Configurar variables de entorno
|
||||||
|
cp wacrm/.env.local.example wacrm/.env.local
|
||||||
|
# Editar wacrm/.env.local con credenciales reales
|
||||||
|
|
||||||
|
# Iniciar todo
|
||||||
|
./scripts/skeen-start-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verificación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Estado de servicios
|
||||||
|
ss -tlnp | grep -E "3000|5173|8069|8000|8090|8644|8080|9119"
|
||||||
|
|
||||||
|
# Health checks
|
||||||
|
curl http://localhost:8069/web/health
|
||||||
|
curl http://localhost:8090/health
|
||||||
|
curl http://localhost:8069/skeen/frontend/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URLs y Puertos
|
||||||
|
|
||||||
|
| Servicio | URL Local | Puerto | Descripción |
|
||||||
|
|----------|-----------|--------|-------------|
|
||||||
|
| Frontend React | http://localhost:5173 | 5173 | Dev server Vite |
|
||||||
|
| Frontend React (prod) | http://192.168.10.114 | 80 | Nginx |
|
||||||
|
| Odoo | http://localhost:8069 | 8069 | ERP/CRM |
|
||||||
|
| WACRM | http://localhost:3000 | 3000 | Inbox WhatsApp |
|
||||||
|
| Supabase Kong | http://localhost:8000 | 8000 | API Gateway |
|
||||||
|
| Supabase PostgreSQL | localhost:5433 | 5433 | Base de datos |
|
||||||
|
| Hermes Gateway | http://localhost:8080 | 8080 | API del agente |
|
||||||
|
| Hermes Serve | http://localhost:9119 | 9119 | Dashboard Hermes |
|
||||||
|
| Bridge Webhook | http://localhost:8090 | 8090 | Webhook WACRM→Hermes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentación Detallada
|
||||||
|
|
||||||
|
| Documento | Contenido |
|
||||||
|
|-----------|-----------|
|
||||||
|
| [docs/arquitectura.md](docs/arquitectura.md) | Arquitectura técnica, diagramas, decisiones de diseño. |
|
||||||
|
| [docs/instalacion.md](docs/instalacion.md) | Guía paso a paso de instalación y configuración. |
|
||||||
|
| [docs/configuracion.md](docs/configuracion.md) | Variables de entorno, credenciales, endpoints. |
|
||||||
|
| [docs/api.md](docs/api.md) | Referencia de endpoints REST de Odoo y WACRM. |
|
||||||
|
| [docs/skills-hermes.md](docs/skills-hermes.md) | Documentación de skills de Hermes y prompts de Sofía. |
|
||||||
|
| [docs/migracion.md](docs/migracion.md) | Guía de migración de datos legacy a Odoo. |
|
||||||
|
| [docs/seguridad.md](docs/seguridad.md) | Políticas de seguridad, manejo de secretos, HMAC. |
|
||||||
|
| [docs/mantenimiento.md](docs/mantenimiento.md) | Monitoreo, backups, troubleshooting. |
|
||||||
|
| [docs/ANALISIS_SKEEN_SISTEMA.md](docs/ANALISIS_SKEEN_SISTEMA.md) | Análisis del sistema legacy actual. |
|
||||||
|
| [docs/ROADMAP_SKEEN_MEJORA.md](docs/ROADMAP_SKEEN_MEJORA.md) | Roadmap de mejoras y fases del proyecto. |
|
||||||
|
| [docs/ESTADO_PROYECTO.md](docs/ESTADO_PROYECTO.md) | Estado detallado del proyecto. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datos del Cliente Pendientes
|
||||||
|
|
||||||
|
Cuando SKEEN entregue estos datos, se conectan en los siguientes lugares:
|
||||||
|
|
||||||
|
| Dato | Dónde se usa | Archivo / UI |
|
||||||
|
|------|--------------|--------------|
|
||||||
|
| Número WhatsApp Business verificado | Meta Developers + WACRM | WACRM Settings → WhatsApp |
|
||||||
|
| Meta App ID + App Secret | WACRM webhook signature | `wacrm/.env.local` (`META_APP_SECRET`) |
|
||||||
|
| Meta Access Token + WABA ID + Phone Number ID | Envío/recepción WhatsApp | WACRM Settings → WhatsApp |
|
||||||
|
| Templates de WhatsApp aprobados | Automatizaciones y campañas | WACRM + Meta Business Manager |
|
||||||
|
| Lista de servicios y precios (CSV) | Catálogo + Odoo | `migracion/importar_servicios_inventario.py` |
|
||||||
|
| Horarios de atención | Agendamiento | Skill `skeen-agendar` + Odoo `skeen_citas` |
|
||||||
|
| Stripe / MercadoPago keys | Pagos | Odoo `skeen_pagos` + skill `skeen-pagos` |
|
||||||
|
| Dominio skeen.mx | DNS + Nginx | Configuración DNS |
|
||||||
|
| Logo / branding | WACRM + Frontend | Assets y configuración |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comandos de Operación
|
||||||
|
|
||||||
|
### Iniciar / Detener
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Iniciar todo el ecosistema
|
||||||
|
./scripts/skeen-start-all.sh
|
||||||
|
|
||||||
|
# Detener todo
|
||||||
|
./scripts/skeen-stop-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /tmp/wacrm.log
|
||||||
|
tail -f /tmp/hermes-bridge.log
|
||||||
|
tail -f /tmp/odoo.log
|
||||||
|
tail -f /tmp/frontend.log
|
||||||
|
tail -f /tmp/hermes-gateway.log
|
||||||
|
tail -f /tmp/hermes-serve.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simular mensaje de WhatsApp
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/simular_meta_webhook.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Acceder a bases de datos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PostgreSQL Odoo
|
||||||
|
sudo -u postgres psql -d skeen_odoo
|
||||||
|
|
||||||
|
# PostgreSQL Supabase
|
||||||
|
docker exec supabase-db psql -U postgres -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recompilar WACRM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd wacrm && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recompilar Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build
|
||||||
|
cp -r dist/* /var/www/skeen-frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
- **No commitear secretos:** `.env`, `.env.local`, `*.key`, `*.pem`, tokens de Meta, keys de Stripe.
|
||||||
|
- **Webhooks WACRM:** Firman con HMAC-SHA256 usando `WACRM_WEBHOOK_SECRET`.
|
||||||
|
- **Odoo API:** Autenticación con API key para endpoints JSON-RPC.
|
||||||
|
- **Supabase:** Row-Level Security (RLS) habilitado en tablas sensibles.
|
||||||
|
- **Supabase self-hosted:** Solo escuchar en localhost o red interna.
|
||||||
|
- **HTTPS:** Configurar SSL en producción con Let's Encrypt.
|
||||||
|
|
||||||
|
Ver [docs/seguridad.md](docs/seguridad.md) para más detalles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contribución
|
||||||
|
|
||||||
|
1. Crear rama feature: `git checkout -b feature/nombre-feature`
|
||||||
|
2. Commits atómicos y descriptivos.
|
||||||
|
3. Abrir Pull Request en Gitea.
|
||||||
|
4. Code review obligatorio antes de merge a `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Este proyecto es propiedad de **Consultoría Alcaraz Salazar, S.A.S.** y **SKEEN Derma Experts**.
|
||||||
|
|
||||||
|
Componentes open-source:
|
||||||
|
- **WACRM:** MIT License
|
||||||
|
- **Odoo:** LGPL v3
|
||||||
|
- **Supabase:** Apache 2.0
|
||||||
|
- **Hermes:** Ver licencia en https://github.com/NousResearch/Hermes-Agent
|
||||||
|
- **React / Vite / Tailwind:** MIT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contacto
|
||||||
|
|
||||||
|
**Consultoría Alcaraz Salazar, S.A.S.**
|
||||||
|
**Desarrollador:** Iván Alcaraz
|
||||||
|
**Email:** ialcarazsalazar@consultoria-as.com
|
||||||
|
**Gitea:** https://git.consultoria-as.com/consultoria-as/SKEEN-Proyecto
|
||||||
289
docs/ANALISIS_SKEEN_SISTEMA.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# Análisis del Sistema Actual de SKEEN Derma Experts
|
||||||
|
## URL analizada: https://sistema.skeenmx.app/
|
||||||
|
|
||||||
|
**Fecha:** 6 de julio de 2026
|
||||||
|
**Usuario de análisis:** JoseSkeen
|
||||||
|
**Restricción:** Solo lectura, sin modificaciones en el sistema original.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Tecnología base
|
||||||
|
|
||||||
|
- **Framework:** Laravel (PHP) — se identifican tokens `_token`, cookies `laravel_session`, estructura de rutas RESTful.
|
||||||
|
- **Frontend:** jQuery 1.12 + Bootstrap/AdminLTE 2.3.5 + CSS propio (`panel.css`, `app.css`).
|
||||||
|
- **Iconos:** Font Awesome 6.2.0 (regular, solid, brands).
|
||||||
|
- **Componentes UI:** DataTables, datepickers, modales Bootstrap, bxslider (comentado).
|
||||||
|
- **No es SPA:** cada sección recarga la página; el layout es sidebar izquierdo + header superior + contenido central.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Identidad visual
|
||||||
|
|
||||||
|
### Paleta de colores predominante
|
||||||
|
| Color | Uso |
|
||||||
|
|-------|-----|
|
||||||
|
| `#1abc9c` / `#16a085` | Acento principal (botones, iconos, estados activos) |
|
||||||
|
| `#BED4AD` | Verde pastel secundario (usado en badges / categorías) |
|
||||||
|
| `#34495e` / `#2c3e50` / `#222f3d` | Textos oscuros, sidebar, header |
|
||||||
|
| `#f1c40f` | Amarillo alerta / metas |
|
||||||
|
| `#e74c3c` | Rojo error / cancelaciones |
|
||||||
|
| `#2ecc71` | Verde éxito |
|
||||||
|
| `#3498db` / `#48c9b0` | Azules informativos |
|
||||||
|
| `#ffffff` / `#f5f5f5` | Fondos claros |
|
||||||
|
| `#bdc3c7` / `#ccc` / `#999` | Grises de borde y texto secundario |
|
||||||
|
|
||||||
|
### Tipografía
|
||||||
|
- **Principal:** `Lato`, Helvetica, Arial, sans-serif.
|
||||||
|
- **Iconos:** Font Awesome (versión 6.2).
|
||||||
|
- **Logo:** `logo_skeen.png` — tipografía sans-serif negra, letras mayúsculas, registro ®.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- **Sidebar izquierdo** con menú principal + submenús desplegables (`treeview`).
|
||||||
|
- **Header superior** fijo con:
|
||||||
|
- Contactos rápidos (teléfono, WhatsApp, Facebook, Instagram, email, sitio web).
|
||||||
|
- Tipo de cambio (TC: 16.50).
|
||||||
|
- Selector de sucursal (Rosarito / Tijuana).
|
||||||
|
- Meta de ventas con progress bar (ej. "Noviembre 2020 — 60% Complete — 600 USD / 1000 USD").
|
||||||
|
- Perfil de usuario, notas y chat interno.
|
||||||
|
- **Contenido central** con breadcrumb "Inicio / Sección", título de página, filtros, tablas DataTables y modales para acciones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mapa de navegación
|
||||||
|
|
||||||
|
### Menú principal (top-level)
|
||||||
|
| Ruta | Sección |
|
||||||
|
|------|---------|
|
||||||
|
| `/dashboard` | Inicio / Movimientos diarios |
|
||||||
|
| `/agenda` | Agenda de citas |
|
||||||
|
| `/visitas` | Visitas registradas |
|
||||||
|
| `/expedientes` | Expedientes de pacientes |
|
||||||
|
| `/medicos` | Médicos |
|
||||||
|
| `/cosmetologos` | Cosmetología |
|
||||||
|
| `/consultas` | Consultas Médicas |
|
||||||
|
|
||||||
|
### Configuración
|
||||||
|
| Ruta | Función |
|
||||||
|
|------|---------|
|
||||||
|
| `/usuarios/consulta/activados` | Usuarios del sistema |
|
||||||
|
| `/recetas` | Catálogo de recetas |
|
||||||
|
| `/diagnosticos` | Catálogo de diagnósticos |
|
||||||
|
| `/procedimientos` | Catálogo de procedimientos médicos |
|
||||||
|
| `/servicios` | Catálogo de servicios |
|
||||||
|
| `/etiquetas` | Etiquetas |
|
||||||
|
| `/productos` | Productos |
|
||||||
|
| `/inventarios` | Inventarios de productos |
|
||||||
|
| `/tipo_cambio` | Tipo de cambio USD/MXN |
|
||||||
|
|
||||||
|
### Reportes
|
||||||
|
| Ruta | Función |
|
||||||
|
|------|---------|
|
||||||
|
| `/reportes/diario` | Mov. diario |
|
||||||
|
| `/reportes/medicos` | Mov. diario por médico |
|
||||||
|
| `/reportes/recepcion` | Mov. diario por recepcionista |
|
||||||
|
| `/reportes/cortes` | Cortes de caja |
|
||||||
|
| `/reportes/ingresos` | Ingresos |
|
||||||
|
| `/reportes/comisiones` | Comisiones |
|
||||||
|
|
||||||
|
### Administrativo
|
||||||
|
| Ruta | Función |
|
||||||
|
|------|---------|
|
||||||
|
| `/reportes/adeudos` | Adeudos clientes |
|
||||||
|
| `/reportes/pagos` | Pagos por servicios |
|
||||||
|
| `/reportes/clientes` | Pagos por clientes |
|
||||||
|
| `/reportes/visitas` | Visitas |
|
||||||
|
| `/reportes/devoluciones` | Devoluciones |
|
||||||
|
| `/reportes/vendedores` | Vendedores |
|
||||||
|
| `/reportes/recomendaciones` | Recomendaciones |
|
||||||
|
| `/reportes/concentrado` | Concentrado |
|
||||||
|
| `/reportes/top/clientes` | Top ventas |
|
||||||
|
| `/reportes/agenda` | Horas agenda |
|
||||||
|
| `/reportes/paquetes` | Paquetes |
|
||||||
|
|
||||||
|
### Monedero
|
||||||
|
| Ruta | Función |
|
||||||
|
|------|---------|
|
||||||
|
| `/monederos/consulta` | Monederos electrónicos |
|
||||||
|
| `/reportes/monedero` | Reporte de puntos |
|
||||||
|
| `/configuracion/monedero` | Lista de precios y % de puntos |
|
||||||
|
|
||||||
|
### Comunicación
|
||||||
|
| Ruta | Función |
|
||||||
|
|------|---------|
|
||||||
|
| `/reportes/cumple` | Cumpleañeros |
|
||||||
|
|
||||||
|
### Consumibles
|
||||||
|
| Ruta | Función |
|
||||||
|
|------|---------|
|
||||||
|
| `/consumibles/inventario` | Inventario actual |
|
||||||
|
| `/consumibles/ventas/consulta` | Comprobantes de consumibles |
|
||||||
|
| `/consumibles/compras/consulta` | Compras |
|
||||||
|
| `/consumibles/bajas` | Bajas |
|
||||||
|
| `/consumibles/consulta` | Consumibles |
|
||||||
|
|
||||||
|
### CRM
|
||||||
|
| Ruta | Función |
|
||||||
|
|------|---------|
|
||||||
|
| `/crm` | Inicio CRM (prospectos, clientes potenciales, expedientes) |
|
||||||
|
| `/crm/medio/{id}` | Filtrar por medio (llamada, WhatsApp, Facebook, Instagram, email, sitio web) |
|
||||||
|
|
||||||
|
### Ventas / Pedidos
|
||||||
|
| Ruta | Función |
|
||||||
|
|------|---------|
|
||||||
|
| `/admin/pedidos/dia` | Ventas del día |
|
||||||
|
| `/admin/pedidos/factura` | Factura |
|
||||||
|
| `/admin/pedidos/anticipo` | Anticipo |
|
||||||
|
| `/admin/pedidos/clientes` | Clientes con adeudos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Funcionalidades clave identificadas
|
||||||
|
|
||||||
|
### Agenda
|
||||||
|
- Filtro por sucursal (Rosarito / Tijuana).
|
||||||
|
- Filtro por médico.
|
||||||
|
- Tabs: Citas, Nueva cita, Lista de espera.
|
||||||
|
- Estados de cita: Pre-consulta, Link enviado, Aprobación, Asignar expediente, Agendar.
|
||||||
|
- Medio: Videollamada / Consultorio.
|
||||||
|
- Acciones: Forma de pago, agendar, enviar WhatsApp.
|
||||||
|
|
||||||
|
### Expedientes
|
||||||
|
- Filtros: VIP, visitó en últimos 6 meses, no ha visitado en 6 meses.
|
||||||
|
- Columnas: ID, folio, nombre, motivo, médico, cosmetología, estatus, cabina, consentimientos, ticket.
|
||||||
|
- Estados: Nueva visita, visita recurrente.
|
||||||
|
|
||||||
|
### Médicos / Cosmetología
|
||||||
|
- Directorio de personal con nombre, apellidos, celular, WhatsApp.
|
||||||
|
- Últimos 30 días de actividad.
|
||||||
|
- Roles: médico, cosmetología, recepción, administrador.
|
||||||
|
|
||||||
|
### Servicios
|
||||||
|
- Catálogo con: ID, Categoría, Concepto, Precio, Activado, Favoritos, Color, Grupo, Etiquetas.
|
||||||
|
- Ordenar favoritos.
|
||||||
|
|
||||||
|
### Productos
|
||||||
|
- Catálogo con: ID, Línea/Categoría, Producto, Descripción, Precio, Activado, Favoritos.
|
||||||
|
|
||||||
|
### Monedero
|
||||||
|
- Configuración de porcentajes de puntos por forma de pago y moneda.
|
||||||
|
- Transferencia USD: 0%
|
||||||
|
- Transferencia M.N.: 0%
|
||||||
|
- Tarjeta USD: 1.5%
|
||||||
|
- Tarjeta M.N.: 1.5%
|
||||||
|
- Efectivo USD: 2%
|
||||||
|
- Efectivo M.N.: 2%
|
||||||
|
- Lista de precios con flags: Otorga puntos, Canjeable por puntos.
|
||||||
|
|
||||||
|
### Movimientos diarios / Ventas
|
||||||
|
- Tabla de ventas con: ID, Expediente, Dr. principal, Concepto, Subtotal, Desc., Pagado, Adeudo, Pendiente.
|
||||||
|
- Formas de pago: Efectivo M.N./USD, Tarjeta M.N./USD, Transferencia M.N./USD, Saldo a favor M.N./USD, Monedero electrónico M.N./USD.
|
||||||
|
- Acciones: Cancelar venta, paquete terminado, devolución.
|
||||||
|
- Modal "Valoración Express" para captura rápida de prospectos.
|
||||||
|
|
||||||
|
### Cortes de caja
|
||||||
|
- Histórico por día.
|
||||||
|
- Totales por médico, recepcionista, productos entregados.
|
||||||
|
- Modal para establecer corte con monto M.N., dólares, observaciones.
|
||||||
|
|
||||||
|
### Adeudos
|
||||||
|
- Separación: adeudos activos (1 año y medio a la fecha) vs pasivos (anteriores).
|
||||||
|
- Agrupación por médico.
|
||||||
|
- Totales por clínica y por doctor.
|
||||||
|
|
||||||
|
### Inventarios
|
||||||
|
- Productos: stock, niveles (óptimo, bajo, crítico, sin existencias).
|
||||||
|
- Consumibles: valor de inventario, niveles, caducidad, último conteo físico.
|
||||||
|
- Ajustes, compras, bajas.
|
||||||
|
|
||||||
|
### CRM
|
||||||
|
- Prospectos por medio de contacto.
|
||||||
|
- Ficha de prospecto con:
|
||||||
|
- Datos personales (nombre, teléfonos, WhatsApp, correo).
|
||||||
|
- Historial de visitas.
|
||||||
|
- Citas futuras.
|
||||||
|
- Saldo / adeudo.
|
||||||
|
- Notas internas.
|
||||||
|
- Perfil, mensajes, settings.
|
||||||
|
- Botones rápidos: llamada, WhatsApp, Facebook, Instagram, email, sitio web.
|
||||||
|
|
||||||
|
### Comunicación
|
||||||
|
- Cumpleañeros: lista de pacientes con fecha de cumpleaños, edad, concepto, subtotal, promo, pagado, adeudo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Flujos de negocio identificados
|
||||||
|
|
||||||
|
1. **Captación de prospecto**
|
||||||
|
- Medio: WhatsApp / llamada / FB / IG / email / sitio web.
|
||||||
|
- Registro en CRM → seguimiento → agenda de valoración.
|
||||||
|
|
||||||
|
2. **Agendamiento**
|
||||||
|
- Agenda con médico, sucursal, tipo de cita (videollamada/presencial).
|
||||||
|
- Estados: pre-consulta, aprobación, link enviado, asignar expediente.
|
||||||
|
|
||||||
|
3. **Visita / Expediente**
|
||||||
|
- Llegada del paciente → creación de visita → expediente médico.
|
||||||
|
- Diagnóstico, procedimiento, receta.
|
||||||
|
|
||||||
|
4. **Venta**
|
||||||
|
- Concepto (servicio/producto), subtotal, descuento, pagado, adeudo.
|
||||||
|
- Múltiples formas de pago y monedas.
|
||||||
|
- Acumulación de puntos según forma de pago.
|
||||||
|
|
||||||
|
5. **Pago / Adeudo**
|
||||||
|
- Pagos parciales, saldo a favor, monedero electrónico.
|
||||||
|
- Reportes de adeudos por edad y por médico.
|
||||||
|
|
||||||
|
6. **Corte de caja**
|
||||||
|
- Cierre diario con totales por recepcionista y médico.
|
||||||
|
|
||||||
|
7. **Inventario**
|
||||||
|
- Entradas (compras), salidas (ventas/bajas), ajustes, niveles de stock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Gaps y oportunidades de mejora
|
||||||
|
|
||||||
|
| # | Gap actual | Oportunidad en nuevo sistema |
|
||||||
|
|---|-----------|------------------------------|
|
||||||
|
| 1 | UI antigua (jQuery/Bootstrap 2015) | Migrar a React 19 + Tailwind con diseño moderno y responsive |
|
||||||
|
| 2 | Sin chatbot / IA | Integrar WACRM + Hermes (Sofía) para atención 24/7 vía WhatsApp |
|
||||||
|
| 3 | CRM básico | Usar WACRM pipelines + contactos + automatizaciones no-code |
|
||||||
|
| 4 | Agenda manual sin recordatorios automáticos | Heartbeat/cron de Hermes para recordatorios 24h y follow-ups |
|
||||||
|
| 5 | No hay inbox multi-agente | WACRM shared inbox con asignación, notas internas, handoff |
|
||||||
|
| 6 | Pagos no integrados con Stripe/MP | Módulo `skeen_pagos` + skills de Hermes para links de pago |
|
||||||
|
| 7 | Monedero sin reglas claras de redención | Reglas: 1 punto = $1 MXN, máx 50% redimible, vencimiento 12 meses |
|
||||||
|
| 8 | Reportes estáticos | Dashboards interactivos con Recharts en frontend |
|
||||||
|
| 9 | Sin unificación Odoo ↔ frontend | API REST propia en Odoo (`skeen_whatsapp/controllers/frontend.py`) |
|
||||||
|
| 10 | Login propio, sin single sign-on | Evaluar Supabase Auth para WACRM + Odoo (opcional) |
|
||||||
|
| 11 | Dependencia de sistema legacy | Replicar funcionalidad en Odoo 17 + módulos custom |
|
||||||
|
| 12 | No hay broadcasting de campañas | WACRM broadcasts con templates aprobados por Meta |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Datos maestros a migrar
|
||||||
|
|
||||||
|
- **Pacientes/Clientes:** nombre, apellidos, teléfonos, WhatsApp, correo, fecha de nacimiento, médico principal, etiquetas, VIP.
|
||||||
|
- **Expedientes médicos:** diagnósticos, procedimientos, recetas, visitas, consentimientos.
|
||||||
|
- **Servicios:** categoría, concepto, precio, favorito, color, grupo, etiquetas.
|
||||||
|
- **Productos:** línea/categoría, producto, descripción, precio, stock, inventario mínimo.
|
||||||
|
- **Médicos / Cosmetólogos / Usuarios:** datos de contacto, roles, comisiones.
|
||||||
|
- **Monederos:** saldo, historial de puntos, reglas de acumulación/redención.
|
||||||
|
- **Adeudos / Pagos:** saldo pendiente, historial de pagos, formas de pago.
|
||||||
|
- **Consumibles:** inventario, caducidad, compras, bajas.
|
||||||
|
- **Agenda:** citas históricas y futuras.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Notas técnicas
|
||||||
|
|
||||||
|
- El sistema original usa Apache + Laravel + PHP.
|
||||||
|
- Algunas imágenes están rotas (`img_logo_derma_blanco.png` devuelve 404).
|
||||||
|
- El login expone el logo en blanco (`logo_skeen_white.png`) sobre fondo oscuro.
|
||||||
|
- Hay chat interno (`chat_conversaciones`, `chat_usuarios`) implementado en el frontend.
|
||||||
|
- El header muestra "TC: 16.50" como tipo de cambio dinámico.
|
||||||
|
- La meta de ventas es visible en el header con progress bar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento generado para análisis. No se realizaron modificaciones en https://sistema.skeenmx.app/*
|
||||||
246
docs/ESTADO_PROYECTO.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Estado del Proyecto SKEEN Derma Experts
|
||||||
|
## WACRM + Hermes + Odoo + Frontend
|
||||||
|
|
||||||
|
**Fecha:** 6 de julio de 2026 (actualizado)
|
||||||
|
**VM:** Hetzner VPS (22 GB RAM / 6 vCPU / 77 GB SSD)
|
||||||
|
**Objetivo:** Sistema instalado y funcionando con datos demo; análisis del sistema actual de SKEEN completado; roadmap de mejora definido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Resumen de lo que funciona
|
||||||
|
|
||||||
|
| Componente | Estado | URL / Puerto | Notas |
|
||||||
|
|------------|--------|--------------|-------|
|
||||||
|
| Supabase self-hosted | ✅ | http://localhost:8000 (Kong), PostgreSQL :5433 | Contenedores Docker healthy. Datos persistentes en volúmenes Docker. |
|
||||||
|
| WACRM | ✅ | http://localhost:3000 | Build Next.js 16 con endpoint `/api/v1/messages/simulate` para modo demo. |
|
||||||
|
| Odoo 17 | ✅ | http://localhost:8069 | Datos demo y de migración importados. Nuevos modelos WACRM cachean conversaciones/mensajes/leads. |
|
||||||
|
| Frontend React | ✅ | http://localhost:5173 / http://192.168.10.114/ | Diseño "SKEEN Brand". Menú WACRM con Mensajes y Leads. Build desplegado en nginx. |
|
||||||
|
| Hermes Gateway | ✅ | API :8080 | Corriendo; bridge propio complementa la integración. |
|
||||||
|
| Bridge Webhook SKEEN | ✅ | http://localhost:8090 | Recibe webhooks de WACRM, consulta Nan builders, responde como Sofía. |
|
||||||
|
| Integración WACRM ↔ Hermes | ✅ | Via automatización `send_webhook` | Flujo end-to-end validado: mensaje de prueba → Sofía → respuesta en WACRM. |
|
||||||
|
| Frontend ↔ Odoo | ✅ | Proxy Vite `/api/odoo` | Dashboard `medical` muestra stats reales. |
|
||||||
|
| WACRM ↔ Odoo proxy | ✅ | `/skeen/frontend/v1/wacrm/*` | Odoo cachea contactos, conversaciones, mensajes y leads de Supabase. Frontend los consume. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 URLs de acceso
|
||||||
|
|
||||||
|
Desde la VM:
|
||||||
|
- WACRM: http://localhost:3000
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- Odoo: http://localhost:8069
|
||||||
|
- Supabase Studio: http://localhost:8000/project/default
|
||||||
|
- Bridge health: http://localhost:8090/health
|
||||||
|
|
||||||
|
Desde red externa (si los puertos están abiertos):
|
||||||
|
- Reemplaza `localhost` por la IP pública de la VM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Diseño de frontend
|
||||||
|
|
||||||
|
Se mantiene un único diseño oficial: **SKEEN Brand** (`/designs/brand`).
|
||||||
|
|
||||||
|
- Inspirado directamente en la identidad de marca de SKEEN.
|
||||||
|
- Tipografía serif (Playfair Display), tonos stone/cálidos, hero con tagline, navegación redondeada, contacto visible.
|
||||||
|
- La raíz (`/`) redirige automáticamente a `/designs/brand`.
|
||||||
|
- Consume datos reales de Odoo vía el proxy `/api/odoo`.
|
||||||
|
|
||||||
|
Los otros 9 diseños experimentales fueron eliminados para enfocar el trabajo en una sola identidad visual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Flujo end-to-end validado
|
||||||
|
|
||||||
|
Se simuló un mensaje de WhatsApp entrante usando `/root/simular_meta_webhook.py` y también directamente al bridge:
|
||||||
|
|
||||||
|
1. Meta webhook simulado → `POST /api/whatsapp/webhook` (WACRM)
|
||||||
|
2. WACRM crea contacto + conversación + mensaje `customer`
|
||||||
|
3. WACRM dispara automatización `new_message_received`
|
||||||
|
4. Automatización ejecuta `send_webhook` a `http://localhost:8090/webhook/wacrm`
|
||||||
|
5. Bridge extrae `from`, `text`, `conversation_id`, `contact_id`
|
||||||
|
6. Bridge consulta a Nan builders (`deepseek-v4-flash`) con el system prompt de Sofía
|
||||||
|
7. Nan builders genera respuesta
|
||||||
|
8. Bridge envía respuesta a WACRM `/api/v1/messages` → falla porque no hay token Meta
|
||||||
|
9. Bridge hace fallback a `/api/v1/messages/simulate` → respuesta guardada en WACRM como mensaje `agent`
|
||||||
|
|
||||||
|
**Resultado:** la conversación en WACRM muestra mensaje del paciente + respuesta de Sofía.
|
||||||
|
|
||||||
|
## ✅ Ronda de pruebas recientes (5 jul 2026)
|
||||||
|
|
||||||
|
| Prueba | Resultado | Detalle |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
| Build frontend (`npm run build`) | ✅ | CSS 56 kB, JS 816 kB, sin errores de Tailwind |
|
||||||
|
| Linter (`npm run lint`) | ✅ | 0 warnings, 0 errors |
|
||||||
|
| Odoo update módulos custom | ✅ | `skeen_pacientes`, `skeen_citas`, `skeen_monedero`, `skeen_pagos`, `skeen_whatsapp` actualizados |
|
||||||
|
| Odoo endpoints | ✅ | `/health`, `/dashboard`, `/appointments`, `/patients`, `/wallets`, `/services`, `/weekly_chart` responden |
|
||||||
|
| Frontend proxy a Odoo | ✅ | `/api/odoo/skeen/frontend/v1/health` responde vía Vite proxy |
|
||||||
|
| 10 diseños cargan | ✅ | Diseño "brand" responde HTTP 200; los otros 9 fueron eliminados |
|
||||||
|
| Bridge webhook | ✅ | `POST /webhook/wacrm` responde con respuesta de Sofía |
|
||||||
|
| WACRM app | ✅ | Responde en `:3000`, modo demo con simulate endpoint |
|
||||||
|
| Supabase self-hosted | ✅ | Todos los contenedores healthy |
|
||||||
|
|
||||||
|
## 🔧 Cambios técnicos recientes en Odoo
|
||||||
|
|
||||||
|
- **WACRM proxy en Odoo**: se agregaron modelos `skeen.wacrm.*` y controladores REST en `skeen_whatsapp/controllers/wacrm_proxy.py` para sincronizar contactos, conversaciones, mensajes, pipelines, stages y deals desde Supabase hacia Odoo.
|
||||||
|
- **Menú WACRM en frontend**: nuevas páginas `/wacrm/messages` y `/wacrm/leads` con sincronización manual y visualización de datos.
|
||||||
|
- **Script de migración mejorado**: `/root/migracion/importar_a_odoo.py` ahora importa pacientes, servicios, productos, médicos, citas, ventas, pagos, adeudos y monederos desde CSVs.
|
||||||
|
- **Variables de entorno Supabase**: `skeen-start-all.sh` exporta `SUPABASE_URL` y `SUPABASE_SERVICE_ROLE_KEY` para que Odoo pueda leer WACRM directamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Cómo iniciar / detener todo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Iniciar todo
|
||||||
|
/root/skeen-start-all.sh
|
||||||
|
|
||||||
|
# Detener todo
|
||||||
|
/root/skeen-stop-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperar ~20-30 segundos después de iniciar. Verificar puertos:
|
||||||
|
```bash
|
||||||
|
ss -tlnp | grep -E "3000|5173|8069|8000|8090|8644|8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Archivos clave modificados/creados
|
||||||
|
|
||||||
|
| Ruta | Propósito |
|
||||||
|
|------|-----------|
|
||||||
|
| `/root/ANALISIS_SKEEN_SISTEMA.md` | Análisis completo del sistema actual https://sistema.skeenmx.app/ |
|
||||||
|
| `/root/ROADMAP_SKEEN_MEJORA.md` | Roadmap para replicar/mejorar el sistema actual en Odoo + WACRM + Frontend + Hermes |
|
||||||
|
| `/root/skeen-frontend/src/App.tsx` | Rutas simplificadas: solo diseño SKEEN Brand, `/` redirige a `/designs/brand`. |
|
||||||
|
| `/root/skeen-frontend/src/designs/brand/Dashboard.tsx` | Único diseño oficial del frontend. |
|
||||||
|
| `/root/skeen-frontend/vite.config.ts` | Plugin `@tailwindcss/vite` configurado. |
|
||||||
|
| `/root/skeen-odoo/addons/skeen_monedero/models/res_partner.py` | Campos `wallet_points` y `monedero_ids` en el módulo correcto. |
|
||||||
|
| `/root/skeen-odoo/addons/skeen_citas/models/cita.py` | Campo `time_str` compute. |
|
||||||
|
| `/root/skeen-odoo/addons/skeen_*/security/ir.model.access.csv` | Permisos de acceso completados. |
|
||||||
|
| `/root/skeen-odoo/addons/skeen_whatsapp/controllers/wacrm_proxy.py` | Proxy/caché WACRM ↔ Odoo. |
|
||||||
|
| `/root/skeen-odoo/addons/skeen_whatsapp/models/wacrm_sync.py` | Modelos Odoo para cachear datos de WACRM. |
|
||||||
|
| `/root/skeen-frontend/src/pages/WacrmMessages.tsx` | Página de conversaciones/mensajes de WACRM. |
|
||||||
|
| `/root/skeen-frontend/src/pages/WacrmLeads.tsx` | Página de leads/deals de WACRM. |
|
||||||
|
| `/root/migracion/importar_a_odoo.py` | Script de migración CSV → Odoo. |
|
||||||
|
| `/root/migracion/README.md` | Instrucciones y formatos de migración. |
|
||||||
|
| `/root/skeen-start-all.sh` | Inicia todo el ecosistema (con env vars de Supabase). |
|
||||||
|
| `/root/skeen-stop-all.sh` | Detiene todo el ecosistema. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Datos del cliente pendientes por conectar
|
||||||
|
|
||||||
|
Cuando SKEEN entregue estos datos, se conectan en los siguientes lugares:
|
||||||
|
|
||||||
|
| Dato | Dónde se usa | Archivo / UI |
|
||||||
|
|------|--------------|--------------|
|
||||||
|
| Número WhatsApp Business verificado | Meta Developers + WACRM | WACRM Settings → WhatsApp |
|
||||||
|
| Meta App ID + App Secret | WACRM webhook signature | `/root/skeen-wacrm/.env.local` (`META_APP_SECRET`) |
|
||||||
|
| Meta Access Token + WABA ID + Phone Number ID | Envío/recepción WhatsApp | WACRM Settings → WhatsApp |
|
||||||
|
| Templates de WhatsApp aprobados | Automatizaciones y campañas | WACRM + Meta Business Manager |
|
||||||
|
| Lista de servicios y precios | Skill de catálogo + Odoo | `/root/skeen-roadmap-hermes/hermes/webhook-bridge.js` (SKEEN_SYSTEM_PROMPT) y Odoo `product.template` |
|
||||||
|
| Horarios de atención | Agendamiento | Skill `skeen-agendar` (a crear) + Odoo `skeen_citas` |
|
||||||
|
| Stripe / MercadoPago keys | Pagos | Odoo módulo `skeen_pagos` y skill `skeen-pagos` |
|
||||||
|
| Dominio skeen.mx | DNS + Nginx | Aún no configurado; actualmente todo en localhost |
|
||||||
|
| Logo / branding | WACRM fork + frontend | `/root/skeen-wacrm` y `/root/skeen-frontend` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Notas técnicas importantes
|
||||||
|
|
||||||
|
1. **Modo demo / Meta no conectado**
|
||||||
|
- Como no hay tokens reales de Meta, WACRM no puede enviar mensajes reales por WhatsApp.
|
||||||
|
- Se creó el endpoint `/api/v1/messages/simulate` para que el flujo end-to-end funcione en demo.
|
||||||
|
- Cuando Meta se conecte, el bridge usará automáticamente `/api/v1/messages` real; el fallback solo se activa ante errores de Meta.
|
||||||
|
|
||||||
|
2. **API key de WACRM**
|
||||||
|
- Creada en Supabase tabla `api_keys` con scopes: `messages:send`, `messages:read`, `contacts:read`, `contacts:write`, `conversations:read`, `broadcasts:send`, `webhooks:manage`.
|
||||||
|
- El bridge la lee de `WACRM_API_KEY` (ver `/root/skeen-roadmap-hermes/hermes/webhook-bridge.js`).
|
||||||
|
|
||||||
|
3. **Automatización en WACRM**
|
||||||
|
- Nombre: "Nuevo mensaje → Webhook Hermes Bridge"
|
||||||
|
- Trigger: `new_message_received`
|
||||||
|
- Step: `send_webhook` a `http://localhost:8090/webhook/wacrm`
|
||||||
|
- Body template: `{"from":"{{ vars.contact_phone }}","text":"{{ message.text }}","conversation_id":"{{ vars.conversation_id }}","contact_id":"{{ vars.contact_id }}"}`
|
||||||
|
|
||||||
|
4. **Supabase self-hosted**
|
||||||
|
- PostgreSQL expuesto en puerto `5433` (no el 5432 directo).
|
||||||
|
- Para conectar `psql` desde fuera del contenedor usar el puerto 5433 con `external_id` o conectarse por `docker exec supabase-db psql -U postgres -d postgres`.
|
||||||
|
|
||||||
|
5. **Hermes vs OpenClaw / Bridge actual**
|
||||||
|
- El roadmap original usaba OpenClaw; se sustituyó por Hermes (`https://api.nan.builders/v1`).
|
||||||
|
- El chat nativo de Hermes no se configuró; en su lugar el bridge llama directamente a la API REST de nan.builders para generar respuestas de Sofía.
|
||||||
|
- Los skills de Hermes (`skeen-rag`, `skeen-agendar`, etc.) están creados en `/root/skeen-roadmap-hermes/hermes/skills/` y se activarán cuando el gateway de Hermes se integre formalmente como receptor de webhooks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Próximos pasos sugeridos
|
||||||
|
|
||||||
|
Ahora que se completó el análisis del sistema actual, el trabajo se enfoca en replicar y mejorar cada sección. Ver el roadmap completo en `/root/ROADMAP_SKEEN_MEJORA.md`.
|
||||||
|
|
||||||
|
1. **Migración de datos maestros (FASE 0)** — *listo para ejecutar*
|
||||||
|
- SKEEN debe proporcionar exportación autorizada de su sistema actual.
|
||||||
|
- Colocar CSVs en `/root/migracion/datos/` y ejecutar `python3 /root/migracion/importar_a_odoo.py`.
|
||||||
|
|
||||||
|
2. **Frontend fiel al sistema actual (FASE 1)**
|
||||||
|
- El menú WACRM ya está operativo. Siguiente: replicar secciones restantes del sistema legacy (visitas, expedientes médicos, catálogos, reportes).
|
||||||
|
|
||||||
|
3. **Conexión Odoo ↔ Frontend (FASE 2)**
|
||||||
|
- Extender endpoints REST de Odoo para todas las secciones.
|
||||||
|
- Crear cliente API en React y conectar cada pantalla.
|
||||||
|
|
||||||
|
4. **WhatsApp + WACRM + Hermes (FASE 3)**
|
||||||
|
- Conectar número de WhatsApp Business de SKEEN.
|
||||||
|
- Configurar automatizaciones, skills de Hermes, templates de Meta.
|
||||||
|
- Habilitar inbox multi-agente y handoff a humano.
|
||||||
|
|
||||||
|
5. **Reportes, analytics y go-live (FASES 4-5)**
|
||||||
|
- Dashboards con Recharts, metas de ventas, exportaciones.
|
||||||
|
- Dominios SSL, backups, capacitación y go-live gradual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Comandos útiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver todos los servicios
|
||||||
|
ss -tlnp | grep -E "3000|5173|8069|8000|8090|8644|8080|5433|6379"
|
||||||
|
|
||||||
|
# Logs en tiempo real
|
||||||
|
tail -f /tmp/wacrm.log
|
||||||
|
tail -f /tmp/hermes-bridge.log
|
||||||
|
tail -f /tmp/odoo.log
|
||||||
|
tail -f /tmp/frontend.log
|
||||||
|
tail -f /tmp/hermes-gateway.log
|
||||||
|
|
||||||
|
# Simular mensaje de WhatsApp entrante
|
||||||
|
python3 /root/simular_meta_webhook.py
|
||||||
|
|
||||||
|
# Estado de contenedores Supabase
|
||||||
|
cd /root/skeen-supabase/docker && docker compose ps
|
||||||
|
|
||||||
|
# Acceder a PostgreSQL de Supabase
|
||||||
|
docker exec supabase-db psql -U postgres -d postgres
|
||||||
|
|
||||||
|
# Recompilar WACRM tras cambios
|
||||||
|
cd /root/skeen-wacrm && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✉️ Handoff
|
||||||
|
|
||||||
|
El sistema está instalado y funcionando. Se completó:
|
||||||
|
|
||||||
|
- ✅ Menú WACRM en el frontend (Mensajes y Leads) con sincronización manual desde Supabase.
|
||||||
|
- ✅ Modelos y endpoints proxy en Odoo para cachear contactos, conversaciones, mensajes, pipelines, stages y deals de WACRM.
|
||||||
|
- ✅ Script de migración mejorado (`/root/migracion/importar_a_odoo.py`) probado con datos de ejemplo.
|
||||||
|
- ✅ Frontend desplegado en nginx (`http://192.168.10.114/`).
|
||||||
|
- ✅ Odoo actualizado y corriendo con variables de entorno de Supabase.
|
||||||
|
|
||||||
|
**Bloqueo ético/legal:** no se realizó extracción masiva de datos desde `https://sistema.skeenmx.app/` porque requiere autorización documentada del cliente. El script de migración está listo para ejecutarse tan pronto como SKEEN entregue una exportación autorizada (CSVs o dump SQL).
|
||||||
|
|
||||||
|
Próximo paso prioritario: que SKEEN proporcione exportación autorizada de pacientes, citas, historial, ventas, pagos y monederos para ejecutar la migración real.
|
||||||
|
|
||||||
|
Para cualquier duda, revisar los logs, `/root/ANALISIS_SKEEN_SISTEMA.md` y `/root/ROADMAP_SKEEN_MEJORA.md`.
|
||||||
167
docs/ROADMAP_SKEEN_MEJORA.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Roadmap de Mejora — SKEEN Derma Experts
|
||||||
|
## Unificación: Odoo 17 + WACRM + Frontend React + Hermes
|
||||||
|
|
||||||
|
**Fecha:** 6 de julio de 2026
|
||||||
|
**Base del análisis:** Sistema actual https://sistema.skeenmx.app/ (Laravel + AdminLTE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetivo general
|
||||||
|
|
||||||
|
Replicar y mejorar toda la funcionalidad del sistema actual de SKEEN en una nueva arquitectura unificada:
|
||||||
|
|
||||||
|
- **Odoo 17** como ERP/ backend maestro (pacientes, expedientes, citas, ventas, pagos, inventarios, contabilidad).
|
||||||
|
- **WACRM** como CRM WhatsApp + shared inbox + automatizaciones.
|
||||||
|
- **Hermes** como agente IA (Sofía) para atención automática, agendamiento, pagos, recordatorios.
|
||||||
|
- **Frontend React 19 + Tailwind CSS** como interfaz moderna, replicando la identidad visual y los flujos del sistema actual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fases del roadmap
|
||||||
|
|
||||||
|
### FASE 0: Fundamentos y migración de datos (1 semana)
|
||||||
|
|
||||||
|
| # | Tarea | Entregable |
|
||||||
|
|---|-------|------------|
|
||||||
|
| 0.1 | Exportar datos maestros del sistema Laravel | CSV/Excel de pacientes, servicios, productos, médicos, usuarios, citas, ventas, adeudos, monederos |
|
||||||
|
| 0.2 | Definir mapeo de datos Laravel → Odoo | Documento de migración |
|
||||||
|
| 0.3 | Cargar pacientes en Odoo (`res.partner`) | Pacientes en Odoo con teléfono, email, etiquetas, médico principal |
|
||||||
|
| 0.4 | Cargar servicios y productos | `skeen.servicio` + `product.template` |
|
||||||
|
| 0.5 | Cargar médicos, cosmetólogos y usuarios | `hr.employee` / `res.users` |
|
||||||
|
| 0.6 | Cargar saldos de monedero | `skeen.monedero` |
|
||||||
|
| 0.7 | Cargar adeudos históricos | `account.move` en estado posted |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 1: Frontend fiel al sistema actual (2 semanas)
|
||||||
|
|
||||||
|
| # | Tarea | Entregable |
|
||||||
|
|---|-------|------------|
|
||||||
|
| 1.1 | Crear tema "SKEEN Classic" en el frontend | Layout con sidebar + header superior + contenido central, colores `#1abc9c`, `#BED4AD`, `#34495e` |
|
||||||
|
| 1.2 | Implementar login con logo SKEEN | Pantalla de login estilo sistema actual |
|
||||||
|
| 1.3 | Replicar menú de navegación completo | Sidebar con grupos: principal, configuración, reportes, administrativo, monedero, comunicación, consumibles, CRM, ventas |
|
||||||
|
| 1.4 | Dashboard / Movimientos diarios | Totales por forma de pago, tabla de ventas del día, filtros por médico/sucursal |
|
||||||
|
| 1.5 | Agenda | Calendario/semana con citas, filtros, estados, acciones |
|
||||||
|
| 1.6 | Expedientes | Lista con filtros VIP/visitas, ficha del paciente |
|
||||||
|
| 1.7 | Catálogos (servicios, productos, diagnósticos, procedimientos, recetas, etiquetas) | CRUD completo |
|
||||||
|
| 1.8 | Médicos, cosmetólogos, usuarios | Directorio con roles y permisos |
|
||||||
|
| 1.9 | Inventarios | Productos + consumibles con niveles de stock |
|
||||||
|
| 1.10 | Monedero | Configuración de % y reporte de puntos |
|
||||||
|
| 1.11 | Reportes administrativos | Adeudos, pagos, visitas, devoluciones, comisiones, concentrado, top clientes, paquetes |
|
||||||
|
| 1.12 | Cortes de caja | Histórico diario, establecer corte |
|
||||||
|
| 1.13 | Tipo de cambio | Pantalla de configuración USD/MXN |
|
||||||
|
| 1.14 | Comunicación / Cumpleañeros | Lista de pacientes cumpleañeros |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 2: Conexión Odoo ↔ Frontend (1 semana)
|
||||||
|
|
||||||
|
| # | Tarea | Entregable |
|
||||||
|
| 2.1 | Extender controladores Odoo (`skeen_whatsapp/controllers/frontend.py`) | Endpoints REST para todas las secciones |
|
||||||
|
| 2.2 | Crear servicios en React (`services/skeenApi.ts`) | Cliente tipado para todos los endpoints |
|
||||||
|
| 2.3 | Conectar dashboard con datos reales | Stats y ventas del día desde Odoo |
|
||||||
|
| 2.4 | Conectar agenda, expedientes, catálogos | CRUD funcional end-to-end |
|
||||||
|
| 2.5 | Autenticación frontend ↔ Odoo | Login validado contra Odoo o token JWT |
|
||||||
|
| 2.6 | Manejo de permisos por rol | Ocultar/mostrar secciones según rol |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 3: WhatsApp + WACRM + Hermes (2 semanas)
|
||||||
|
|
||||||
|
| # | Tarea | Entregable |
|
||||||
|
|---|-------|------------|
|
||||||
|
| 3.1 | Conectar número de WhatsApp Business de SKEEN en WACRM | WACRM enviando/recibiendo mensajes reales |
|
||||||
|
| 3.2 | Configurar automatizaciones en WACRM | Nuevo mensaje → webhook → Hermes bridge |
|
||||||
|
| 3.3 | Entrenar a Sofía con el catálogo real | Skill `skeen-rag` con servicios/precios de SKEEN |
|
||||||
|
| 3.4 | Skill de agendamiento con Odoo | `skeen-agendar` consulta disponibilidad y crea citas |
|
||||||
|
| 3.5 | Skill de pagos | `skeen-pagos` genera links Stripe/MP |
|
||||||
|
| 3.6 | Skill de monedero | `skeen-monedero` consulta saldo y redime puntos |
|
||||||
|
| 3.7 | Skill de recordatorios | `skeen-recordatorios` vía cron cada 30 min |
|
||||||
|
| 3.8 | Templates de Meta aprobados | Bienvenida, confirmación cita, recordatorio, follow-up, re-engage |
|
||||||
|
| 3.9 | WACRM inbox para equipo SKEEN | Recepcionistas y médicos con acceso |
|
||||||
|
| 3.10 | Handoff a humano | Trigger "AGENTE"/"HUMANO" en WhatsApp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 4: Reportes, analytics y mejoras UX (1 semana)
|
||||||
|
|
||||||
|
| # | Tarea | Entregable |
|
||||||
|
| 4.1 | Dashboards interactivos con Recharts | Ingresos, visitas, top servicios, conversiones |
|
||||||
|
| 4.2 | Meta de ventas en header | Progress bar igual al sistema actual |
|
||||||
|
| 4.3 | Filtros avanzados en tablas | DataTables-like con búsqueda, ordenamiento, exportación |
|
||||||
|
| 4.4 | Exportar a Excel/PDF | Botones de exportación en reportes |
|
||||||
|
| 4.5 | Chat interno | Mensajería entre usuarios (opcional, puede usarse WACRM) |
|
||||||
|
| 4.6 | Notas internas en pacientes | Igual al sistema actual |
|
||||||
|
| 4.7 | Modales rápidos | Valoración Express, cancelar venta, devolución, paquete terminado |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FASE 5: Migración final, testing y go-live (1 semana)
|
||||||
|
|
||||||
|
| # | Tarea | Entregable |
|
||||||
|
| 5.1 | Migración completa de datos históricos | Odoo con datos reales de SKEEN |
|
||||||
|
| 5.2 | Testing de todos los flujos | Documento de pruebas firmado |
|
||||||
|
| 5.3 | Capacitación del equipo | Sesiones con recepción, médicos, administración |
|
||||||
|
| 5.4 | Configurar dominios y SSL | `crm.skeen.mx`, `erp.skeen.mx`, `app.skeen.mx` |
|
||||||
|
| 5.5 | Monitoreo y backups | Uptime Kuma + dumps diarios |
|
||||||
|
| 5.6 | Go-live gradual | Soft launch + soporte presencial/remoto |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura objetivo
|
||||||
|
|
||||||
|
```
|
||||||
|
Paciente → WhatsApp → Meta Cloud API → WACRM
|
||||||
|
↓ (webhook)
|
||||||
|
Hermes Bridge
|
||||||
|
↓
|
||||||
|
Hermes Gateway (Sofía)
|
||||||
|
↓
|
||||||
|
Odoo 17 (ERP maestro)
|
||||||
|
↗ ↖
|
||||||
|
Frontend React Supabase Auth/Storage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack tecnológico final
|
||||||
|
|
||||||
|
| Capa | Tecnología |
|
||||||
|
|------|-----------|
|
||||||
|
| Backend ERP | Odoo 17 Community + módulos custom |
|
||||||
|
| CRM WhatsApp | WACRM (fork propio) |
|
||||||
|
| Agente IA | Hermes / Nan builders API |
|
||||||
|
| Frontend | React 19 + TypeScript + Tailwind CSS + Vite |
|
||||||
|
| Base de datos | PostgreSQL (Odoo) + Supabase PostgreSQL (WACRM) |
|
||||||
|
| Auth | Supabase Auth para WACRM; Odoo para backend |
|
||||||
|
| Charts | Recharts |
|
||||||
|
| Tablas | TanStack Table |
|
||||||
|
| Deploy | Docker Compose + Nginx + Let's Encrypt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorización MVP
|
||||||
|
|
||||||
|
Para el go-live mínimo viable, se deben tener listas estas 5 áreas:
|
||||||
|
|
||||||
|
1. **Pacientes + Expedientes** (Odoo + Frontend)
|
||||||
|
2. **Agenda + Citas** (Odoo + Frontend + recordatorios por WhatsApp)
|
||||||
|
3. **Ventas + Pagos + Monedero** (Odoo + Frontend + Hermes)
|
||||||
|
4. **WhatsApp con Sofía** (WACRM + Hermes)
|
||||||
|
5. **Dashboard de ingresos y cortes de caja**
|
||||||
|
|
||||||
|
Las demás secciones (consumibles, reportes avanzados, CRM pipelines, comisiones) pueden activarse en iteraciones posteriores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Métricas de éxito
|
||||||
|
|
||||||
|
- Todos los usuarios del sistema actual pueden realizar sus tareas diarias en el nuevo sistema.
|
||||||
|
- Reducción de no-shows ≥ 30% gracias a recordatorios automáticos.
|
||||||
|
- Tiempo de atención a prospectos por WhatsApp reducido con Sofía 24/7.
|
||||||
|
- Datos históricos accesibles y consultables en Odoo.
|
||||||
|
- Cero dependencia del sistema Laravel legacy para operaciones diarias.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Roadmap basado en el análisis de https://sistema.skeenmx.app/ sin realizar modificaciones.*
|
||||||
738
docs/api.md
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
# API Reference — SKEEN Derma Experts
|
||||||
|
|
||||||
|
**Versión:** 2.0
|
||||||
|
**Fecha:** 16 de julio de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introducción
|
||||||
|
|
||||||
|
Esta documentación describe todos los endpoints REST expuestos por el sistema SKEEN. Los endpoints están organizados por componente:
|
||||||
|
|
||||||
|
- **Odoo Frontend API** (`/skeen/frontend/v1/*`): Para el Frontend React.
|
||||||
|
- **Odoo Hermes API** (`/skeen/api/v1/*`): Para Hermes/skills (JSON-RPC/XML-RPC).
|
||||||
|
- **WACRM API** (`/api/v1/*`): Para WACRM público.
|
||||||
|
- **Bridge API** (`:8090/*`): Para webhooks WACRM → Hermes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Autenticación
|
||||||
|
|
||||||
|
### 2.1 Frontend API (JWT)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login
|
||||||
|
curl -X POST http://localhost:8069/skeen/frontend/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"login":"admin","password":"SkeenAdmin2026!"}'
|
||||||
|
|
||||||
|
# Respuesta
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"token": "eyJ1aWQiOjEsImxvZ2luIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3ODQyMzI3NDV9...",
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"login": "admin",
|
||||||
|
"name": "Administrador SKEEN",
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uso del token
|
||||||
|
curl -H "Authorization: Bearer <token>" http://localhost:8069/skeen/frontend/v1/appointments
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Hermes API (API Key)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8069/skeen/api/v1/available_slots \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer skeen_api_key_2026" \
|
||||||
|
-d '{"service_id":1,"date_from":"2026-07-17","date_to":"2026-07-20"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 WACRM API (Bearer Token)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer wacrm_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"to":"+526641234567","type":"text","text":"Hola"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Odoo Frontend API
|
||||||
|
|
||||||
|
### 3.1 Health
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "skeen-frontend-api",
|
||||||
|
"version": "1.1.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Auth
|
||||||
|
|
||||||
|
#### Login
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/frontend/v1/auth/login
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"login": "admin",
|
||||||
|
"password": "SkeenAdmin2026!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"token": "eyJ...",
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"login": "admin",
|
||||||
|
"name": "Administrador SKEEN",
|
||||||
|
"role": "admin",
|
||||||
|
"must_change_password": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Me
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/auth/me
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Logout
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/frontend/v1/auth/logout
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Listar Usuarios (admin)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/auth/users
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Crear Usuario (admin)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/frontend/v1/auth/users
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"login": "recepcion1",
|
||||||
|
"name": "Recepción 1",
|
||||||
|
"password": "Password123!",
|
||||||
|
"role": "recepcion"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Actualizar Usuario (admin)
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /skeen/frontend/v1/auth/users/<user_id>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Citas
|
||||||
|
|
||||||
|
#### Listar Citas
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/appointments?page=1&page_size=50&date_from=2026-07-01&date_to=2026-07-31&state=confirmed&search=Nombre
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parámetros de query:**
|
||||||
|
- `page`: Número de página (default: 1)
|
||||||
|
- `page_size`: Items por página (default: 50, max: 200)
|
||||||
|
- `date_from`: Fecha inicio (YYYY-MM-DD)
|
||||||
|
- `date_to`: Fecha fin (YYYY-MM-DD)
|
||||||
|
- `state`: Estado (`pending`, `confirmed`, `arrived`, `in_progress`, `done`, `cancelled`, `no_show`)
|
||||||
|
- `patient_id`: ID del paciente
|
||||||
|
- `doctor_id`: ID del médico
|
||||||
|
- `search`: Búsqueda por nombre, paciente o servicio
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"appointments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"reference": "CITA-001",
|
||||||
|
"patient_id": 45044,
|
||||||
|
"patient": "Negrete Ca Mou Cecile",
|
||||||
|
"phone": "526634347178",
|
||||||
|
"service_id": 1,
|
||||||
|
"service": "Consulta Dermatologica",
|
||||||
|
"service_category": "consulta",
|
||||||
|
"date": "2026-07-17",
|
||||||
|
"time": "10:00",
|
||||||
|
"state": "pending",
|
||||||
|
"payment_state": "not_paid",
|
||||||
|
"price": 800.0,
|
||||||
|
"doctor_id": null,
|
||||||
|
"doctor": null,
|
||||||
|
"branch": "rosarito",
|
||||||
|
"medium": "onsite",
|
||||||
|
"notes": "",
|
||||||
|
"package_finished": false,
|
||||||
|
"package_finished_date": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 50,
|
||||||
|
"total_pages": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Crear Cita
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/frontend/v1/appointments
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"partner_id": 45044,
|
||||||
|
"servicio_id": 1,
|
||||||
|
"date": "2026-07-17",
|
||||||
|
"time": 10.0,
|
||||||
|
"sessions": 1,
|
||||||
|
"branch": "rosarito",
|
||||||
|
"medium": "onsite",
|
||||||
|
"notes": "",
|
||||||
|
"state": "pending"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
- `partner_id` (required): ID del paciente
|
||||||
|
- `servicio_id` (required): ID del servicio
|
||||||
|
- `date` (required): Fecha (YYYY-MM-DD)
|
||||||
|
- `time` (required): Hora en float (10.0 = 10:00, 10.5 = 10:30)
|
||||||
|
- `sessions` (optional): Número de sesiones (default: 1). Si el servicio tiene `package_sessions > 0`, se usa ese valor por defecto.
|
||||||
|
- `branch` (optional): `rosarito` o `tijuana`
|
||||||
|
- `medium` (optional): `onsite` o `videocall`
|
||||||
|
- `notes` (optional): Notas
|
||||||
|
- `state` (optional): Estado inicial
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"appointment": { /* primera cita */ },
|
||||||
|
"appointments": [ /* todas las citas creadas */ ],
|
||||||
|
"sessions": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota:** Si `sessions > 1`, se crean N citas separadas. La primera usa la fecha/hora proporcionada; las siguientes se programan cada 7 días a las 9:00 AM.
|
||||||
|
|
||||||
|
#### Obtener Cita
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/appointments/<appointment_id>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Actualizar Cita
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /skeen/frontend/v1/appointments/<appointment_id>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"date": "2026-07-18",
|
||||||
|
"time": 11.0,
|
||||||
|
"state": "confirmed",
|
||||||
|
"notes": "Paciente confirmó por teléfono"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Eliminar Cita
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /skeen/frontend/v1/appointments/<appointment_id>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 Servicios
|
||||||
|
|
||||||
|
#### Listar Servicios
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/services
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"code": "CONS-DERM",
|
||||||
|
"name": "Consulta Dermatologica",
|
||||||
|
"category": "consulta",
|
||||||
|
"price": 800.0,
|
||||||
|
"package_price": 0.0,
|
||||||
|
"package_sessions": 0,
|
||||||
|
"package_notes": "",
|
||||||
|
"duration_min": 30,
|
||||||
|
"description": "Evaluación completa...",
|
||||||
|
"color": "#1abc9c",
|
||||||
|
"service_group": "Consultas",
|
||||||
|
"is_favorite": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Crear Servicio (admin)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/frontend/v1/services
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Peel Quimico",
|
||||||
|
"code": "PEEL-QUI",
|
||||||
|
"category": "tratamiento",
|
||||||
|
"price": 1500.0,
|
||||||
|
"duration_min": 30,
|
||||||
|
"package_price": 4000.0,
|
||||||
|
"package_sessions": 3,
|
||||||
|
"package_notes": "Ahorro de $500",
|
||||||
|
"description": "Exfoliación química...",
|
||||||
|
"color": "#9b59b6",
|
||||||
|
"service_group": "Faciales",
|
||||||
|
"is_favorite": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Actualizar Servicio (admin)
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /skeen/frontend/v1/services/<service_id>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 Pacientes
|
||||||
|
|
||||||
|
#### Listar Pacientes
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/patients?page=1&page_size=50&search=Nombre
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Crear Paciente
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/frontend/v1/patients
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Juan Pérez",
|
||||||
|
"phone": "526641234567",
|
||||||
|
"email": "juan@example.com",
|
||||||
|
"birthdate": "1990-01-15",
|
||||||
|
"gender": "male",
|
||||||
|
"blood_type": "O+",
|
||||||
|
"allergies": "Penicilina",
|
||||||
|
"notes": "Paciente nuevo"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Obtener Paciente
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/patients/<patient_id>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Actualizar Paciente
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /skeen/frontend/v1/patients/<patient_id>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 Monedero
|
||||||
|
|
||||||
|
#### Listar Monederos
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/wallets
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"wallets": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"patient_id": 45044,
|
||||||
|
"patient": "Negrete Ca Mou Cecile",
|
||||||
|
"points": 450,
|
||||||
|
"equivalent_mxn": 450,
|
||||||
|
"transactions": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 Ventas
|
||||||
|
|
||||||
|
#### Listar Ventas
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/sales?date_from=2026-07-01&date_to=2026-07-31
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Crear Venta
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/frontend/v1/sales
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"partner_id": 45044,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"service_id": 1,
|
||||||
|
"qty": 1,
|
||||||
|
"price_unit": 800.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"payment_method": "cash",
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.8 Inventario
|
||||||
|
|
||||||
|
#### Listar Inventario
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/inventory
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.9 WACRM Proxy
|
||||||
|
|
||||||
|
#### Listar Contactos
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/wacrm/contacts?page=1&page_size=50&search=Nombre
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Listar Conversaciones
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/wacrm/conversations?page=1&page_size=50
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Listar Mensajes
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/wacrm/messages?conversation_id=123
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Listar Deals
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /skeen/frontend/v1/wacrm/deals
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sincronizar WACRM → Odoo
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/frontend/v1/wacrm/sync
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"sync_contacts": true,
|
||||||
|
"sync_conversations": true,
|
||||||
|
"sync_messages": true,
|
||||||
|
"sync_deals": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Odoo Hermes API
|
||||||
|
|
||||||
|
Estos endpoints usan `type='json'` y `auth='api_key'`.
|
||||||
|
|
||||||
|
### 4.1 Slots Disponibles
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/api/v1/available_slots
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
|
||||||
|
{
|
||||||
|
"service_id": 1,
|
||||||
|
"date_from": "2026-07-17",
|
||||||
|
"date_to": "2026-07-20"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"slots": [
|
||||||
|
{
|
||||||
|
"date": "2026-07-17",
|
||||||
|
"time": "09:00",
|
||||||
|
"datetime": "2026-07-17 09:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Crear Cita
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/api/v1/create_appointment
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
|
||||||
|
{
|
||||||
|
"phone": "526641234567",
|
||||||
|
"name": "Juan Pérez",
|
||||||
|
"service_id": 1,
|
||||||
|
"date": "2026-07-17",
|
||||||
|
"time": "10:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Saldo del Paciente
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/api/v1/patient_balance
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
|
||||||
|
{
|
||||||
|
"phone": "526641234567"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"pending_balance": 1500.0,
|
||||||
|
"pending_invoices": [
|
||||||
|
{
|
||||||
|
"amount_residual": 1500.0,
|
||||||
|
"name": "INV/2026/0001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wallet_points": 450,
|
||||||
|
"wallet_mxn": 450
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Redimir Puntos
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/api/v1/wallet_redeem
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
|
||||||
|
{
|
||||||
|
"phone": "526641234567",
|
||||||
|
"points": 200,
|
||||||
|
"appointment_id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Cancelar Cita
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /skeen/api/v1/cancel_appointment
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
|
||||||
|
{
|
||||||
|
"phone": "526641234567",
|
||||||
|
"booking_id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. WACRM API
|
||||||
|
|
||||||
|
### 5.1 Enviar Mensaje
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/messages
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"to": "+526641234567",
|
||||||
|
"type": "text",
|
||||||
|
"text": "Hola, tu cita está confirmada"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Simular Mensaje (Demo)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/messages/simulate
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"to": "+526641234567",
|
||||||
|
"type": "text",
|
||||||
|
"text": "Hola, tu cita está confirmada"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Contactos
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/contacts
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Webhooks
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/webhooks
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"url": "https://openclaw.skeen.mx/webhook/wacrm",
|
||||||
|
"events": ["message.received", "message.status_updated"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Bridge API
|
||||||
|
|
||||||
|
### 6.1 Health
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Webhook WACRM
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /webhook/wacrm
|
||||||
|
Content-Type: application/json
|
||||||
|
X-WACRM-Signature: t=...,v1=...
|
||||||
|
|
||||||
|
{
|
||||||
|
"from": "+526641234567",
|
||||||
|
"text": "Hola, quiero agendar una cita",
|
||||||
|
"conversation_id": "123",
|
||||||
|
"contact_id": "456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"received": true,
|
||||||
|
"response": "Hola! Soy Sofia de SKEEN. Claro, ¿qué servicio te interesa?"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Códigos de Error
|
||||||
|
|
||||||
|
| Código | Significado |
|
||||||
|
|--------|-------------|
|
||||||
|
| 200 | OK |
|
||||||
|
| 201 | Creado |
|
||||||
|
| 400 | Bad Request — parámetros inválidos |
|
||||||
|
| 401 | Unauthorized — token inválido o faltante |
|
||||||
|
| 403 | Forbidden — permisos insuficientes |
|
||||||
|
| 404 | Not Found — recurso no encontrado |
|
||||||
|
| 409 | Conflict — conflicto (ej. usuario ya existe) |
|
||||||
|
| 500 | Internal Server Error |
|
||||||
|
|
||||||
|
**Formato de error:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Descripción del error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Rate Limiting
|
||||||
|
|
||||||
|
Actualmente no hay rate limiting implementado. Se recomienda configurar:
|
||||||
|
|
||||||
|
- **nginx:** `limit_req_zone`
|
||||||
|
- **WACRM:** Configurar en middleware
|
||||||
|
- **Odoo:** Configurar en `ir.http`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Versionado
|
||||||
|
|
||||||
|
La API está en versión `1.1.0`. Los cambios se documentan en el changelog de cada módulo.
|
||||||
324
docs/arquitectura.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# Arquitectura Técnica — SKEEN Derma Experts
|
||||||
|
|
||||||
|
**Versión:** 2.0
|
||||||
|
**Fecha:** 16 de julio de 2026
|
||||||
|
**Proyecto:** SKEEN Derma Experts — Sistema Integral de Gestión Clínica
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visión General
|
||||||
|
|
||||||
|
SKEEN Derma Experts es un sistema integral de gestión clínica que unifica:
|
||||||
|
|
||||||
|
- **Comunicación:** WhatsApp Business API (Meta Cloud API)
|
||||||
|
- **IA Conversacional:** Hermes + Qwen3.6 (Nan Builders)
|
||||||
|
- **ERP/CRM:** Odoo 17 Community
|
||||||
|
- **Inbox CRM:** WACRM (Next.js 16 + Supabase)
|
||||||
|
- **Panel de Control:** Frontend React 18
|
||||||
|
- **Base de Datos WACRM:** Supabase self-hosted (PostgreSQL + Auth + Realtime)
|
||||||
|
|
||||||
|
### 1.1 Objetivos de Arquitectura
|
||||||
|
|
||||||
|
1. **Sin vendor lock-in:** Todo self-hostable, código MIT/LGPL.
|
||||||
|
2. **Escalable:** Componentes desacoplados, comunicación via APIs y webhooks.
|
||||||
|
3. **Seguro:** RLS, HMAC, autenticación JWT, secretos fuera del código.
|
||||||
|
4. **Mantenible:** Código modular, documentación extensiva, tests automatizados.
|
||||||
|
5. **Proactivo:** IA que actúa sin input del usuario (recordatorios, follow-ups).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Diagrama de Componentes
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ USUARIOS │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Paciente │ │ Recepcionista│ │ Médico │ │ Admin │ │
|
||||||
|
│ │ (WhatsApp) │ │ (Frontend) │ │ (Frontend) │ │ (Frontend) │ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||||
|
└─────────┼─────────────────┼─────────────────┼─────────────────┼────────────┘
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FRONTEND REACT │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ React 18 + Vite + TypeScript + Tailwind CSS + React Router │ │
|
||||||
|
│ │ Proxy /api/odoo → Odoo REST API │ │
|
||||||
|
│ │ Proxy /api/wacrm → WACRM API │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ nginx (static + reverse proxy) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ODOO 17 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Módulos Custom: │ │
|
||||||
|
│ │ skeen_citas — Citas médicas, disponibilidad, paquetes │ │
|
||||||
|
│ │ skeen_pacientes — Pacientes, expedientes médicos │ │
|
||||||
|
│ │ skeen_monedero — Sistema de puntos/fidelidad │ │
|
||||||
|
│ │ skeen_pagos — Pagos, adeudos, Stripe/MercadoPago (futuro) │ │
|
||||||
|
│ │ skeen_ventas — Ventas, corte de caja, comisiones, metas │ │
|
||||||
|
│ │ skeen_inventario — Inventario de productos y consumibles │ │
|
||||||
|
│ │ skeen_whatsapp — API REST para Hermes/WACRM/Frontend │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ PostgreSQL: skeen_odoo │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ XML-RPC / JSON-RPC
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ HERMES + BRIDGE │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Hermes Gateway (daemon) │ │
|
||||||
|
│ │ - Gateway API :8080 │ │
|
||||||
|
│ │ - Heartbeat cada 30 min │ │
|
||||||
|
│ │ - Skills: skeen-rag, skeen-agendar, skeen-pagos, ... │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Bridge Webhook SKEEN (:8090) │ │
|
||||||
|
│ │ - Recibe webhooks de WACRM │ │
|
||||||
|
│ │ - Consulta Nan Builders API (Qwen3.6) │ │
|
||||||
|
│ │ - Responde a WACRM vía API │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ Webhook HMAC-SHA256
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ WACRM │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Next.js 16 App Router │ │
|
||||||
|
│ │ - Shared inbox multi-agente │ │
|
||||||
|
│ │ - Contactos + tags + custom fields │ │
|
||||||
|
│ │ - Pipelines Kanban (Lead → Consulta → Tratamiento → Recurrente) │ │
|
||||||
|
│ │ - Automatizaciones no-code │ │
|
||||||
|
│ │ - Broadcasts con templates Meta │ │
|
||||||
|
│ │ - API REST pública /api/v1/* │ │
|
||||||
|
│ │ - Webhooks outbound firmados HMAC-SHA256 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Supabase self-hosted (Docker) │ │
|
||||||
|
│ │ - PostgreSQL :5433 │ │
|
||||||
|
│ │ - Auth (JWT) │ │
|
||||||
|
│ │ - Realtime subscriptions │ │
|
||||||
|
│ │ - Storage │ │
|
||||||
|
│ │ - Kong API Gateway :8000 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ Meta Cloud API
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ META WHATSAPP CLOUD API │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ - Recepción de mensajes de WhatsApp │ │
|
||||||
|
│ │ - Envío de mensajes de WhatsApp │ │
|
||||||
|
│ │ - Templates aprobados │ │
|
||||||
|
│ │ - Webhooks a WACRM │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Flujo de Datos Paso a Paso
|
||||||
|
|
||||||
|
### 3.1 Mensaje entrante de WhatsApp
|
||||||
|
|
||||||
|
1. **Meta Cloud API** recibe mensaje del paciente.
|
||||||
|
2. **Meta** envía webhook a **WACRM** (`/api/whatsapp/webhook`).
|
||||||
|
3. **WACRM** crea/actualiza:
|
||||||
|
- Contacto (por número de teléfono).
|
||||||
|
- Conversación.
|
||||||
|
- Mensaje con `sender_type: customer`.
|
||||||
|
4. **WACRM** dispara automatización `new_message_received`.
|
||||||
|
5. **Automatización** ejecuta step `send_webhook`:
|
||||||
|
- URL: `http://localhost:8090/webhook/wacrm`
|
||||||
|
- Body: `{from, text, conversation_id, contact_id}`
|
||||||
|
6. **Bridge SKEEN** recibe el webhook:
|
||||||
|
- Extrae datos.
|
||||||
|
- Construye contexto (historial reciente si existe).
|
||||||
|
- Consulta **Nan Builders API** (Qwen3.6) con el system prompt de Sofía.
|
||||||
|
- Obtiene respuesta de la IA.
|
||||||
|
7. **Bridge** envía respuesta a **WACRM**:
|
||||||
|
- Primero intenta `POST /api/v1/messages` (si hay token Meta).
|
||||||
|
- Si falla, fallback a `POST /api/v1/messages/simulate` (modo demo).
|
||||||
|
8. **WACRM** guarda mensaje con `sender_type: agent`.
|
||||||
|
9. **WACRM** envía mensaje a **Meta Cloud API** (si hay token real).
|
||||||
|
10. **Paciente** recibe respuesta en WhatsApp.
|
||||||
|
|
||||||
|
### 3.2 Agendamiento de cita vía IA
|
||||||
|
|
||||||
|
1. Paciente pide agendar cita en WhatsApp.
|
||||||
|
2. Sofía (Hermes) detecta intención y activa skill `skeen-agendar`.
|
||||||
|
3. Skill consulta **Odoo API** (`/skeen/api/v1/available_slots`) para disponibilidad.
|
||||||
|
4. Sofía presenta opciones al paciente.
|
||||||
|
5. Paciente confirma fecha/hora.
|
||||||
|
6. Skill llama **Odoo API** (`/skeen/api/v1/create_appointment`):
|
||||||
|
- Busca o crea paciente por teléfono.
|
||||||
|
- Crea cita con estado `confirmed`.
|
||||||
|
- Si es paquete, crea N citas separadas.
|
||||||
|
7. Skill envía confirmación via WACRM.
|
||||||
|
8. Cita aparece en **Frontend React** y **WACRM** (sincronización).
|
||||||
|
|
||||||
|
### 3.3 Sincronización WACRM ↔ Odoo
|
||||||
|
|
||||||
|
- **Contactos:** WACRM crea contacto → webhook → Odoo crea `res.partner` con `is_patient=True`.
|
||||||
|
- **Conversaciones:** WACRM → Odoo cachea en `skeen.wacrm.conversation`.
|
||||||
|
- **Mensajes:** WACRM → Odoo cachea en `skeen.wacrm.message`.
|
||||||
|
- **Leads/Deals:** WACRM pipeline → Odoo cachea en `skeen.wacrm.deal`.
|
||||||
|
- **Frontend:** Lee de Odoo vía `/skeen/frontend/v1/wacrm/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Comunicación entre Componentes
|
||||||
|
|
||||||
|
| Canal | Tecnología | Autenticación | Frecuencia |
|
||||||
|
|-------|-----------|---------------|------------|
|
||||||
|
| Meta → WACRM | Webhook HTTPS | Verify Token | Evento |
|
||||||
|
| WACRM → Bridge | Webhook HTTP | HMAC-SHA256 | Evento |
|
||||||
|
| Bridge → Nan Builders | HTTPS REST | API Key | Bajo demanda |
|
||||||
|
| Bridge → WACRM | HTTPS REST | API Key (Bearer) | Bajo demanda |
|
||||||
|
| Hermes → Odoo | XML-RPC / JSON-RPC | API Key | Bajo demanda |
|
||||||
|
| Frontend → Odoo | HTTPS REST | JWT | Bajo demanda |
|
||||||
|
| WACRM → Odoo | HTTPS REST | API Key | Sync cada 5 min |
|
||||||
|
| Odoo → Supabase | PostgreSQL directo | Service Role Key | Bajo demanda |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Stack Tecnológico
|
||||||
|
|
||||||
|
| Capa | Tecnología | Versión | Rol |
|
||||||
|
|------|-----------|---------|-----|
|
||||||
|
| **IA** | Hermes (Nous Research) | latest | Agente autónomo |
|
||||||
|
| **IA Modelo** | Qwen3.6 (Nan Builders) | latest | Generación de respuestas |
|
||||||
|
| **CRM WhatsApp** | WACRM | MIT | Inbox, pipelines, automatizaciones |
|
||||||
|
| **Backend IA** | Node.js | 20+ | Bridge webhook |
|
||||||
|
| **ERP** | Odoo | 17 Community | Citas, ventas, pagos, monedero |
|
||||||
|
| **Base de Datos ERP** | PostgreSQL | 16 | Datos clínicos |
|
||||||
|
| **Base de Datos WACRM** | PostgreSQL (Supabase) | 16 | Datos WACRM |
|
||||||
|
| **Frontend** | React | 18 | Panel de control |
|
||||||
|
| **Frontend Build** | Vite | 5+ | Bundler |
|
||||||
|
| **Frontend Estilos** | Tailwind CSS | 3+ | UI |
|
||||||
|
| **Frontend Lenguaje** | TypeScript | 5+ | Type safety |
|
||||||
|
| **Web Server** | nginx | 1.24+ | Reverse proxy + static |
|
||||||
|
| **Containerización** | Docker + Docker Compose | latest | Supabase self-hosted |
|
||||||
|
| **Sistema Operativo** | Ubuntu | 22.04 | VM Hetzner |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Decisiones de Arquitectura
|
||||||
|
|
||||||
|
### 6.1 WACRM vs Inbox Custom
|
||||||
|
|
||||||
|
**Decisión:** Usar WACRM en lugar de construir inbox propio.
|
||||||
|
|
||||||
|
**Justificación:**
|
||||||
|
- Inbox funcional desde el día 1.
|
||||||
|
- Shared inbox multi-agente nativo.
|
||||||
|
- Pipelines Kanban nativos.
|
||||||
|
- Automatizaciones no-code.
|
||||||
|
- Broadcasts con templates Meta.
|
||||||
|
- Dashboard real-time nativo.
|
||||||
|
- API REST pública completa.
|
||||||
|
- Comunidad activa (MIT).
|
||||||
|
- 60% menos código custom.
|
||||||
|
|
||||||
|
### 6.2 Hermes vs FastAPI + GPT-4o Directo
|
||||||
|
|
||||||
|
**Decisión:** Usar Hermes + Bridge Node.js en lugar de FastAPI custom.
|
||||||
|
|
||||||
|
**Justificación:**
|
||||||
|
- Hermes ya tiene gateway, memoria, skills, heartbeat.
|
||||||
|
- 25+ tools built-in + customs.
|
||||||
|
- Multi-model (OpenAI, Anthropic, Google, Ollama, Nan Builders).
|
||||||
|
- Heartbeat permite acciones proactivas (recordatorios).
|
||||||
|
- Skills modulares fáciles de mantener.
|
||||||
|
- Bridge simple (~200 líneas) vs backend completo.
|
||||||
|
- Comunidad 150k+ stars.
|
||||||
|
|
||||||
|
### 6.3 Supabase Self-Hosted vs Supabase Cloud
|
||||||
|
|
||||||
|
**Decisión:** Self-hosted.
|
||||||
|
|
||||||
|
**Justificación:**
|
||||||
|
- Control total de datos.
|
||||||
|
- Sin límites de tier gratuito.
|
||||||
|
- Sin costos mensuales de Supabase Pro ($25/mes).
|
||||||
|
- Datos en la misma VM que el resto del sistema.
|
||||||
|
- PostgreSQL completo sin restricciones.
|
||||||
|
|
||||||
|
**Costo:** 22 GB RAM de la VM soporta Supabase + Odoo + WACRM + Frontend sin problemas.
|
||||||
|
|
||||||
|
### 6.4 Odoo 17 vs CRM Custom
|
||||||
|
|
||||||
|
**Decisión:** Odoo 17 Community.
|
||||||
|
|
||||||
|
**Justificación:**
|
||||||
|
- ERP/CRM maduro y probado.
|
||||||
|
- Módulos custom fáciles de crear.
|
||||||
|
- PostgreSQL nativo.
|
||||||
|
- API XML-RPC/JSON-RPC completa.
|
||||||
|
- Escalable.
|
||||||
|
- Sin costo de licencia.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Consideraciones de Escalabilidad
|
||||||
|
|
||||||
|
| Componente | Estrategia de Escalabilidad |
|
||||||
|
|------------|----------------------------|
|
||||||
|
| **Frontend** | Static files en CDN (Cloudflare) |
|
||||||
|
| **Odoo** | Múltiples workers, Redis cache, read replicas |
|
||||||
|
| **WACRM** | Horizontal scaling con Supabase, load balancer |
|
||||||
|
| **Hermes** | Múltiples gateways, queue de mensajes |
|
||||||
|
| **Bridge** | Stateless, múltiples instancias |
|
||||||
|
| **PostgreSQL** | Read replicas, connection pooling (pgbouncer) |
|
||||||
|
| **Supabase** | Múltiples nodos, load balancer |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Consideraciones de Seguridad
|
||||||
|
|
||||||
|
1. **Webhooks WACRM:** Firma HMAC-SHA256 en header `X-WACRM-Signature`.
|
||||||
|
2. **API Keys:** Todas las APIs usan Bearer tokens.
|
||||||
|
3. **JWT:** Frontend React usa JWT firmado por Odoo.
|
||||||
|
4. **RLS:** Row-Level Security en Supabase.
|
||||||
|
5. **Secretos:** Nunca commiteados, en variables de entorno.
|
||||||
|
6. **HTTPS:** Configurar en producción.
|
||||||
|
7. **Rate limiting:** Configurar en nginx y WACRM.
|
||||||
|
8. **Input validation:** Validación en todos los endpoints.
|
||||||
|
9. **Audit logs:** Odoo tracking, WACRM audit, bridge logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Alta Disponibilidad y Recuperación
|
||||||
|
|
||||||
|
| Componente | Estrategia |
|
||||||
|
|------------|-----------|
|
||||||
|
| **Supabase** | Docker restart policy `always`, backups diarios |
|
||||||
|
| **Odoo** | systemd auto-restart, backups de PostgreSQL |
|
||||||
|
| **WACRM** | systemd auto-restart |
|
||||||
|
| **Hermes** | systemd auto-restart |
|
||||||
|
| **Bridge** | systemd auto-restart |
|
||||||
|
| **PostgreSQL** | Backups diarios, point-in-time recovery |
|
||||||
|
| **Frontend** | Static, sin estado |
|
||||||
|
| **Monitoreo** | Uptime Kuma (a configurar) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Referencias
|
||||||
|
|
||||||
|
- [WACRM GitHub](https://github.com/ArnasDon/wacrm)
|
||||||
|
- [Hermes Agent](https://github.com/NousResearch/Hermes-Agent)
|
||||||
|
- [Odoo 17 Documentation](https://www.odoo.com/documentation/17.0)
|
||||||
|
- [Supabase Self-Hosting](https://supabase.com/docs/guides/self-hosting)
|
||||||
|
- [Meta WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api)
|
||||||
|
- [Nan Builders API](https://api.nan.builders)
|
||||||
481
docs/configuracion.md
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
# Configuración — SKEEN Derma Experts
|
||||||
|
|
||||||
|
**Versión:** 2.0
|
||||||
|
**Fecha:** 16 de julio de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Variables de Entorno
|
||||||
|
|
||||||
|
### 1.1 Bridge SKEEN (`/root/skeen-roadmap-hermes/hermes/.env`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Nan Builders (IA)
|
||||||
|
NAN_API_KEY=sk-gJ8OWbZVGeWy9ns_H00cxA
|
||||||
|
NAN_BASE_URL=https://api.nan.builders/v1
|
||||||
|
NAN_MODEL=qwen3.6
|
||||||
|
|
||||||
|
# WACRM
|
||||||
|
WACRM_URL=http://localhost:3000
|
||||||
|
WACRM_API_KEY=wacrm_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
|
||||||
|
# Bridge
|
||||||
|
BRIDGE_PORT=8090
|
||||||
|
NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 WACRM (`/root/skeen-wacrm/.env.local`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Supabase (self-hosted on localhost)
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNjQxNzY5MjAwLCJleHAiOjE3OTk1MzU2MDB9.pnl22qqc0WMyNRwqdXq7cEq1hw_YTrdTgWHd0TPsHPU
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE2NDE3NjkyMDAsImV4cCI6MTc5OTUzNTYwMH0.XA05Pgn-gkEKaZEaHYFc9DAyP45uNJdsl8DL9axUxbc
|
||||||
|
|
||||||
|
# Encryption key for WACRM (64 hex chars)
|
||||||
|
ENCRYPTION_KEY=8bbf201d9d60488a1e86e5396b31e22d8bbf201d9d60488a1e86e5396b31e22d
|
||||||
|
|
||||||
|
# WhatsApp / Meta (pending client data)
|
||||||
|
WHATSAPP_PHONE_NUMBER_ID=PENDING_PHONE_ID
|
||||||
|
WHATSAPP_BUSINESS_ACCOUNT_ID=PENDING_WABA_ID
|
||||||
|
WHATSAPP_ACCESS_TOKEN=PENDING_TOKEN
|
||||||
|
WHATSAPP_VERIFY_TOKEN=skeen_webhook_verify_2026
|
||||||
|
|
||||||
|
# Meta App Secret for webhook signature verification
|
||||||
|
META_APP_SECRET=skeen_meta_app_secret_2026_demo
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Odoo (`/root/skeen-odoo/odoo.conf`)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[options]
|
||||||
|
addons_path = /root/skeen-odoo/addons,/root/skeen-odoo/odoo/addons
|
||||||
|
admin_passwd = skeen_admin_2026
|
||||||
|
csv_internal_sep = ,
|
||||||
|
data_dir = /var/lib/odoo
|
||||||
|
db_host = localhost
|
||||||
|
db_maxconn = 64
|
||||||
|
db_name = skeen_odoo
|
||||||
|
db_password = skeen_dev_2026
|
||||||
|
db_port = 5432
|
||||||
|
db_sslmode = prefer
|
||||||
|
db_user = skeen
|
||||||
|
demo = {}
|
||||||
|
email_from = False
|
||||||
|
geoip_database = /usr/share/GeoIP/GeoLite2-City.mmdb
|
||||||
|
http_enable = True
|
||||||
|
http_interface =
|
||||||
|
http_port = 8069
|
||||||
|
import_partial =
|
||||||
|
limit_memory_hard = 2684354560
|
||||||
|
limit_memory_soft = 2147483648
|
||||||
|
limit_request = 8192
|
||||||
|
limit_time_cpu = 60
|
||||||
|
limit_time_real = 120
|
||||||
|
limit_time_real_cron = -1
|
||||||
|
list_db = True
|
||||||
|
log_db = False
|
||||||
|
log_db_level = warning
|
||||||
|
log_handler = :INFO
|
||||||
|
log_level = info
|
||||||
|
logfile =
|
||||||
|
longpolling_port = 8072
|
||||||
|
max_cron_threads = 2
|
||||||
|
osv_memory_age_limit = False
|
||||||
|
osv_memory_count_limit = False
|
||||||
|
pg_path =
|
||||||
|
pidfile =
|
||||||
|
proxy_mode = True
|
||||||
|
reportgz = False
|
||||||
|
screencasts =
|
||||||
|
screenshots = /tmp/odoo_screenshots
|
||||||
|
server_wide_modules = base,web
|
||||||
|
smtp_password = False
|
||||||
|
smtp_port = 25
|
||||||
|
smtp_server = localhost
|
||||||
|
smtp_ssl = False
|
||||||
|
smtp_ssl_certificate_filename = False
|
||||||
|
smtp_ssl_private_key_filename = False
|
||||||
|
smtp_user = False
|
||||||
|
test_file =
|
||||||
|
test_tags = None
|
||||||
|
translate_modules = ['all']
|
||||||
|
unaccent = False
|
||||||
|
upgrade_path =
|
||||||
|
without_demo = False
|
||||||
|
workers = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Scripts de Inicio (`/root/skeen-start-all.sh`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SUPABASE_URL="http://localhost:8000"
|
||||||
|
export SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE2NDE3NjkyMDAsImV4cCI6MTc5OTUzNTYwMH0.XA05Pgn-gkEKaZEaHYFc9DAyP45uNJdsl8DL9axUxbc"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Credenciales y Accesos
|
||||||
|
|
||||||
|
| Servicio | URL | Usuario | Contraseña | Notas |
|
||||||
|
|----------|-----|---------|------------|-------|
|
||||||
|
| Odoo | http://localhost:8069 | admin | skeen_admin_2026 | DB: skeen_odoo |
|
||||||
|
| Frontend | http://localhost:5173 | admin | SkeenAdmin2026! | Usuario seed |
|
||||||
|
| WACRM | http://localhost:3000 | ivan@horuxfin.com | (definida por usuario) | Registro manual |
|
||||||
|
| Supabase Studio | http://localhost:8000 | supabase | (definida en .env) | Dashboard |
|
||||||
|
| PostgreSQL Odoo | localhost:5432 | skeen | skeen_dev_2026 | DB: skeen_odoo |
|
||||||
|
| PostgreSQL Supabase | localhost:5433 | postgres | (definida en .env) | Docker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Endpoints Importantes
|
||||||
|
|
||||||
|
### 3.1 Odoo REST API (`skeen_whatsapp`)
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción | Auth |
|
||||||
|
|----------|--------|-------------|------|
|
||||||
|
| `/skeen/api/v1/available_slots` | POST | Slots disponibles | API Key |
|
||||||
|
| `/skeen/api/v1/create_appointment` | POST | Crear cita | API Key |
|
||||||
|
| `/skeen/api/v1/patient_balance` | POST | Saldo paciente | API Key |
|
||||||
|
| `/skeen/api/v1/wallet_redeem` | POST | Redimir puntos | API Key |
|
||||||
|
| `/skeen/api/v1/cancel_appointment` | POST | Cancelar cita | API Key |
|
||||||
|
|
||||||
|
### 3.2 Odoo Frontend API (`skeen/frontend/v1/*`)
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción | Auth |
|
||||||
|
|----------|--------|-------------|------|
|
||||||
|
| `/skeen/frontend/v1/health` | GET | Health check | None |
|
||||||
|
| `/skeen/frontend/v1/auth/login` | POST | Login | None |
|
||||||
|
| `/skeen/frontend/v1/auth/me` | GET | Usuario actual | JWT |
|
||||||
|
| `/skeen/frontend/v1/appointments` | GET/POST | Citas | JWT |
|
||||||
|
| `/skeen/frontend/v1/appointments/<id>` | GET/PUT/DELETE | Cita individual | JWT |
|
||||||
|
| `/skeen/frontend/v1/services` | GET/POST | Servicios | JWT |
|
||||||
|
| `/skeen/frontend/v1/services/<id>` | PUT | Actualizar servicio | JWT |
|
||||||
|
| `/skeen/frontend/v1/patients` | GET/POST | Pacientes | JWT |
|
||||||
|
| `/skeen/frontend/v1/patients/<id>` | GET/PUT | Paciente individual | JWT |
|
||||||
|
| `/skeen/frontend/v1/wallets` | GET | Monederos | JWT |
|
||||||
|
| `/skeen/frontend/v1/sales` | GET/POST | Ventas | JWT |
|
||||||
|
| `/skeen/frontend/v1/inventory` | GET | Inventario | JWT |
|
||||||
|
| `/skeen/frontend/v1/wacrm/contacts` | GET | Contactos WACRM | JWT |
|
||||||
|
| `/skeen/frontend/v1/wacrm/conversations` | GET | Conversaciones WACRM | JWT |
|
||||||
|
| `/skeen/frontend/v1/wacrm/messages` | GET | Mensajes WACRM | JWT |
|
||||||
|
| `/skeen/frontend/v1/wacrm/deals` | GET | Deals WACRM | JWT |
|
||||||
|
| `/skeen/frontend/v1/wacrm/sync` | POST | Sincronizar WACRM→Odoo | JWT |
|
||||||
|
|
||||||
|
### 3.3 WACRM API (`/api/v1/*`)
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción | Auth |
|
||||||
|
|----------|--------|-------------|------|
|
||||||
|
| `/api/v1/messages` | POST | Enviar mensaje | Bearer Token |
|
||||||
|
| `/api/v1/messages/simulate` | POST | Simular mensaje (demo) | Bearer Token |
|
||||||
|
| `/api/v1/contacts` | GET/POST | Contactos | Bearer Token |
|
||||||
|
| `/api/v1/conversations` | GET | Conversaciones | Bearer Token |
|
||||||
|
| `/api/v1/webhooks` | GET/POST | Webhooks | Bearer Token |
|
||||||
|
| `/api/whatsapp/webhook` | POST | Webhook Meta | Verify Token |
|
||||||
|
|
||||||
|
### 3.4 Bridge SKEEN (`:8090`)
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Health check |
|
||||||
|
| `/webhook/wacrm` | POST | Webhook WACRM → Hermes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Configuración de Hermes
|
||||||
|
|
||||||
|
### 4.1 Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver estado
|
||||||
|
hermes gateway status
|
||||||
|
|
||||||
|
# Iniciar
|
||||||
|
hermes gateway run
|
||||||
|
|
||||||
|
# Instalar como servicio
|
||||||
|
hermes gateway install
|
||||||
|
sudo hermes gateway install --system
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Serve (Dashboard/API)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes serve --host 0.0.0.0 --port 9119 --skip-build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Skills Instalados
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes skills list
|
||||||
|
```
|
||||||
|
|
||||||
|
Skills disponibles:
|
||||||
|
- `skeen-rag`
|
||||||
|
- `skeen-agendar`
|
||||||
|
- `skeen-pagos`
|
||||||
|
- `skeen-monedero`
|
||||||
|
- `skeen-recordatorios`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configuración de Meta WhatsApp Cloud API
|
||||||
|
|
||||||
|
### 5.1 Crear App en Meta Developers
|
||||||
|
|
||||||
|
1. Ir a https://developers.facebook.com
|
||||||
|
2. Crear app tipo "Business"
|
||||||
|
3. Agregar producto "WhatsApp"
|
||||||
|
4. Obtener:
|
||||||
|
- Phone Number ID
|
||||||
|
- WhatsApp Business Account ID (WABA ID)
|
||||||
|
- Access Token (permanente, System User)
|
||||||
|
- App Secret
|
||||||
|
|
||||||
|
### 5.2 Configurar Webhook en Meta
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|-------|
|
||||||
|
| Callback URL | `https://crm.skeen.mx/api/whatsapp/webhook` |
|
||||||
|
| Verify Token | `skeen_webhook_verify_2026` |
|
||||||
|
| Subscription fields | `messages`, `message_template_status_update` |
|
||||||
|
|
||||||
|
### 5.3 Configurar WACRM
|
||||||
|
|
||||||
|
En WACRM Dashboard → Settings → WhatsApp:
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|-------|
|
||||||
|
| Phone Number ID | De Meta Developers |
|
||||||
|
| Access Token | Token permanente |
|
||||||
|
| Business Account ID | WABA ID |
|
||||||
|
| Verify Token | `skeen_webhook_verify_2026` |
|
||||||
|
|
||||||
|
### 5.4 Templates de WhatsApp
|
||||||
|
|
||||||
|
Templates a subir y aprobar en Meta Business Manager:
|
||||||
|
|
||||||
|
| Template | Categoría | Uso |
|
||||||
|
|----------|-----------|-----|
|
||||||
|
| `bienvenida_skeen` | UTILITY | Primer contacto |
|
||||||
|
| `confirmacion_cita` | UTILITY | Confirmación de agendamiento |
|
||||||
|
| `recordatorio_cita` | UTILITY | Recordatorio 24h antes |
|
||||||
|
| `followup_tratamiento` | UTILITY | Seguimiento post-tratamiento |
|
||||||
|
| `reengage_skeen` | MARKETING | Re-engagement pacientes inactivos |
|
||||||
|
| `comprobante_pago` | UTILITY | Confirmación de pago recibido |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Configuración de Supabase
|
||||||
|
|
||||||
|
### 6.1 Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/skeen-supabase/docker
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Variables de Entorno Clave
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=your_password
|
||||||
|
JWT_SECRET=your_jwt_secret
|
||||||
|
ANON_KEY=your_anon_jwt
|
||||||
|
SERVICE_ROLE_KEY=your_service_role_jwt
|
||||||
|
DASHBOARD_USERNAME=supabase
|
||||||
|
DASHBOARD_PASSWORD=your_dashboard_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Acceso a Base de Datos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desde host
|
||||||
|
psql -h localhost -p 5433 -U postgres -d postgres
|
||||||
|
|
||||||
|
# Desde contenedor
|
||||||
|
docker exec supabase-db psql -U postgres -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Configuración de nginx
|
||||||
|
|
||||||
|
### 7.1 Frontend React
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name skeen.consultoria-as.com;
|
||||||
|
root /var/www/skeen-frontend;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/odoo {
|
||||||
|
proxy_pass http://localhost:8069;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/wacrm {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 WACRM (subdominio)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name skeencrm.consultoria-as.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Configuración de Cloudflare Tunnel
|
||||||
|
|
||||||
|
Para exponer servicios sin abrir puertos en el firewall:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar cloudflared
|
||||||
|
curl -fsSL https://pkg.cloudflare.com/cloudflared.repo | sudo tee /etc/apt/sources.list.d/cloudflared.list
|
||||||
|
sudo apt update && sudo apt install cloudflared
|
||||||
|
|
||||||
|
# Autenticar
|
||||||
|
cloudflared tunnel login
|
||||||
|
|
||||||
|
# Crear tunnel
|
||||||
|
cloudflared tunnel create skeen-tunnel
|
||||||
|
|
||||||
|
# Configurar DNS
|
||||||
|
cloudflared tunnel route dns skeen-tunnel skeen.consultoria-as.com
|
||||||
|
|
||||||
|
# Ejecutar tunnel
|
||||||
|
cloudflared tunnel run --token YOUR_TUNNEL_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota:** El tunnel ya está configurado en esta VM con el token proporcionado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Configuración de systemd (Producción)
|
||||||
|
|
||||||
|
### 9.1 Odoo
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Odoo 17 SKEEN
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Environment=SUPABASE_URL=http://localhost:8000
|
||||||
|
Environment=SUPABASE_SERVICE_ROLE_KEY=your_key
|
||||||
|
ExecStart=/root/odoo-venv/bin/python /root/skeen-odoo/odoo-bin -c /root/skeen-odoo/odoo.conf
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 WACRM
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=WACRM SKEEN
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/skeen-wacrm
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
ExecStart=/usr/bin/npm start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Bridge SKEEN
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Bridge Webhook SKEEN
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/skeen-roadmap-hermes/hermes
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
ExecStart=/usr/bin/node webhook-bridge.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Configuración de Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup PostgreSQL Odoo
|
||||||
|
pg_dump -U skeen -h localhost skeen_odoo > /root/backups/odoo_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
# Backup PostgreSQL Supabase
|
||||||
|
docker exec supabase-db pg_dump -U postgres postgres > /root/backups/supabase_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
# Backup archivos
|
||||||
|
tar -czf /root/backups/skeen-files_$(date +%Y%m%d_%H%M%S).tar.gz \
|
||||||
|
/root/skeen-frontend \
|
||||||
|
/root/skeen-wacrm \
|
||||||
|
/root/skeen-odoo/addons/skeen_* \
|
||||||
|
/root/skeen-roadmap-hermes \
|
||||||
|
/root/migracion
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Checklist de Configuración para Go-Live
|
||||||
|
|
||||||
|
- [ ] Número WhatsApp Business verificado por Meta
|
||||||
|
- [ ] Meta App ID + Secret configurados
|
||||||
|
- [ ] Access Token permanente generado
|
||||||
|
- [ ] Phone Number ID y WABA ID configurados en WACRM
|
||||||
|
- [ ] Templates de WhatsApp aprobados por Meta
|
||||||
|
- [ ] Dominio skeen.mx apuntando a la VM
|
||||||
|
- [ ] SSL certificado instalado (Let's Encrypt)
|
||||||
|
- [ ] Firewall configurado (solo 80, 443, 22)
|
||||||
|
- [ ] Backups automatizados configurados
|
||||||
|
- [ ] Monitoreo Uptime Kuma activo
|
||||||
|
- [ ] API keys de WACRM generadas y configuradas
|
||||||
|
- [ ] Variables de entorno en producción
|
||||||
|
- [ ] systemd services habilitados
|
||||||
|
- [ ] Logs rotando correctamente
|
||||||
363
docs/estructura.md
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
# Estructura del Repositorio — SKEEN Derma Experts
|
||||||
|
|
||||||
|
**Versión:** 2.0
|
||||||
|
**Fecha:** 16 de julio de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Estructura General
|
||||||
|
|
||||||
|
```
|
||||||
|
SKEEN-Proyecto/
|
||||||
|
├── README.md # Documentación principal
|
||||||
|
├── .gitignore # Archivos excluidos de Git
|
||||||
|
├── docs/ # Documentación extensiva
|
||||||
|
│ ├── arquitectura.md # Arquitectura técnica
|
||||||
|
│ ├── instalacion.md # Guía de instalación
|
||||||
|
│ ├── configuracion.md # Variables y credenciales
|
||||||
|
│ ├── api.md # Referencia de endpoints
|
||||||
|
│ ├── skills-hermes.md # Skills de IA
|
||||||
|
│ ├── migracion.md # Guía de migración
|
||||||
|
│ ├── seguridad.md # Políticas de seguridad
|
||||||
|
│ ├── mantenimiento.md # Operación y troubleshooting
|
||||||
|
│ ├── estructura.md # Este archivo
|
||||||
|
│ ├── ANALISIS_SKEEN_SISTEMA.md # Análisis sistema legacy
|
||||||
|
│ ├── ESTADO_PROYECTO.md # Estado detallado
|
||||||
|
│ └── ROADMAP_SKEEN_MEJORA.md # Roadmap de mejoras
|
||||||
|
├── frontend/ # Frontend React (SKEEN Brand)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # Componentes reutilizables
|
||||||
|
│ │ ├── pages/ # Páginas/rutas
|
||||||
|
│ │ ├── services/ # Cliente API (odoo.ts)
|
||||||
|
│ │ ├── designs/ # Diseños de UI
|
||||||
|
│ │ │ └── brand/ # Diseño oficial SKEEN Brand
|
||||||
|
│ │ ├── hooks/ # Custom hooks
|
||||||
|
│ │ ├── utils/ # Utilidades
|
||||||
|
│ │ ├── App.tsx # Componente raíz
|
||||||
|
│ │ └── main.tsx # Punto de entrada
|
||||||
|
│ ├── public/ # Assets estáticos
|
||||||
|
│ ├── index.html # HTML base
|
||||||
|
│ ├── package.json # Dependencias npm
|
||||||
|
│ ├── vite.config.ts # Configuración Vite
|
||||||
|
│ ├── tailwind.config.js # Configuración Tailwind
|
||||||
|
│ └── tsconfig.json # Configuración TypeScript
|
||||||
|
├── frontend-homenest/ # Frontend alternativo (Homenest)
|
||||||
|
│ └── (misma estructura que frontend/)
|
||||||
|
├── odoo-addons/ # Módulos Odoo custom
|
||||||
|
│ ├── skeen_citas/ # Citas médicas
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ └── cita.py # Modelo skeen.cita, skeen.servicio
|
||||||
|
│ │ ├── views/
|
||||||
|
│ │ ├── security/
|
||||||
|
│ │ │ └── ir.model.access.csv
|
||||||
|
│ │ ├── data/
|
||||||
|
│ │ │ └── sequences.xml
|
||||||
|
│ │ └── __manifest__.py
|
||||||
|
│ ├── skeen_pacientes/ # Pacientes y expedientes
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ ├── res_partner.py # Extensión res.partner
|
||||||
|
│ │ │ └── hr_employee.py # Extensión hr.employee
|
||||||
|
│ │ └── __manifest__.py
|
||||||
|
│ ├── skeen_monedero/ # Sistema de puntos/fidelidad
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ ├── monedero.py # Modelo skeen.monedero
|
||||||
|
│ │ │ └── res_partner.py # Campos en res.partner
|
||||||
|
│ │ └── __manifest__.py
|
||||||
|
│ ├── skeen_pagos/ # Pagos y adeudos
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ └── pago.py # Modelo skeen.pago
|
||||||
|
│ │ └── __manifest__.py
|
||||||
|
│ ├── skeen_ventas/ # Ventas y corte de caja
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ ├── venta.py # Modelo skeen.venta
|
||||||
|
│ │ │ └── tipo_cambio.py # Tipo de cambio
|
||||||
|
│ │ ├── views/
|
||||||
|
│ │ └── __manifest__.py
|
||||||
|
│ ├── skeen_inventario/ # Inventario
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ └── inventario.py # Modelo skeen.inventario.item
|
||||||
|
│ │ └── __manifest__.py
|
||||||
|
│ └── skeen_whatsapp/ # API REST para integraciones
|
||||||
|
│ ├── controllers/
|
||||||
|
│ │ ├── frontend.py # Endpoints para Frontend React
|
||||||
|
│ │ ├── main.py # Endpoints para Hermes/WACRM
|
||||||
|
│ │ └── wacrm_proxy.py # Proxy WACRM ↔ Odoo
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── frontend_user.py # Usuarios del frontend
|
||||||
|
│ │ └── wacrm_sync.py # Modelos cache WACRM
|
||||||
|
│ ├── data/
|
||||||
|
│ │ └── frontend_users.xml # Usuario seed admin
|
||||||
|
│ └── __manifest__.py
|
||||||
|
├── wacrm/ # WACRM (fork)
|
||||||
|
│ ├── src/ # Código fuente Next.js
|
||||||
|
│ ├── public/ # Assets
|
||||||
|
│ ├── docs/ # Documentación WACRM
|
||||||
|
│ ├── .env.local.example # Template de variables
|
||||||
|
│ └── package.json
|
||||||
|
├── hermes/ # Hermes + Bridge + Skills
|
||||||
|
│ ├── hermes/ # Configuración Hermes
|
||||||
|
│ │ ├── skills/ # Skills de Hermes
|
||||||
|
│ │ │ ├── skeen-rag/ # Catálogo servicios
|
||||||
|
│ │ │ ├── skeen-agendar/ # Agendamiento
|
||||||
|
│ │ │ ├── skeen-pagos/ # Pagos
|
||||||
|
│ │ │ ├── skeen-monedero/ # Monedero
|
||||||
|
│ │ │ └── skeen-recordatorios/ # Recordatorios
|
||||||
|
│ │ ├── workspace/ # Workspace Hermes
|
||||||
|
│ │ ├── webhook-bridge.js # Bridge WACRM → Hermes
|
||||||
|
│ │ └── config.yaml # Configuración
|
||||||
|
│ ├── skills/ # Skills adicionales
|
||||||
|
│ │ └── hermes-skeen/
|
||||||
|
│ ├── docs/
|
||||||
|
│ ├── infra/
|
||||||
|
│ └── docker-compose.dev.yml
|
||||||
|
├── migracion/ # Scripts de migración
|
||||||
|
│ ├── importar_a_odoo.py # Migración principal
|
||||||
|
│ ├── importar_servicios_inventario.py # Servicios + inventario
|
||||||
|
│ ├── extraer_skeen.py # Extracción legacy
|
||||||
|
│ ├── borrar_datos_demo.py # Limpieza demo
|
||||||
|
│ ├── datos/ # CSVs de datos (no commitear)
|
||||||
|
│ └── README.md # Documentación migración
|
||||||
|
├── scripts/ # Scripts de operación
|
||||||
|
│ ├── skeen-start-all.sh # Iniciar todo
|
||||||
|
│ ├── skeen-stop-all.sh # Detener todo
|
||||||
|
│ ├── skeen-start-odoo.sh # Iniciar solo Odoo
|
||||||
|
│ ├── odoo-test-data.py # Pruebas datos Odoo
|
||||||
|
│ └── simular_meta_webhook.py # Simulador webhook Meta
|
||||||
|
└── infra/ # Infraestructura
|
||||||
|
├── odoo.conf # Configuración Odoo
|
||||||
|
└── docker-compose.dev.yml # Desarrollo local
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Archivos Clave por Componente
|
||||||
|
|
||||||
|
### 2.1 Frontend React
|
||||||
|
|
||||||
|
| Archivo | Propósito |
|
||||||
|
|---------|-----------|
|
||||||
|
| `frontend/src/App.tsx` | Rutas principales, redirige a `/designs/brand` |
|
||||||
|
| `frontend/src/designs/brand/Dashboard.tsx` | Dashboard principal |
|
||||||
|
| `frontend/src/services/odoo.ts` | Cliente API para Odoo |
|
||||||
|
| `frontend/src/pages/Agenda.tsx` | Calendario de citas |
|
||||||
|
| `frontend/src/pages/Pacientes.tsx` | Gestión de pacientes |
|
||||||
|
| `frontend/src/pages/Servicios.tsx` | Catálogo de servicios |
|
||||||
|
| `frontend/src/pages/WacrmMessages.tsx` | Mensajes de WACRM |
|
||||||
|
| `frontend/src/pages/WacrmLeads.tsx` | Leads/pipelines de WACRM |
|
||||||
|
| `frontend/vite.config.ts` | Proxy `/api/odoo` → Odoo |
|
||||||
|
|
||||||
|
### 2.2 Odoo
|
||||||
|
|
||||||
|
| Archivo | Propósito |
|
||||||
|
|---------|-----------|
|
||||||
|
| `odoo-addons/skeen_citas/models/cita.py` | Modelos citas y servicios |
|
||||||
|
| `odoo-addons/skeen_whatsapp/controllers/frontend.py` | API Frontend React |
|
||||||
|
| `odoo-addons/skeen_whatsapp/controllers/main.py` | API Hermes/WACRM |
|
||||||
|
| `odoo-addons/skeen_whatsapp/controllers/wacrm_proxy.py` | Proxy WACRM ↔ Odoo |
|
||||||
|
| `odoo-addons/skeen_whatsapp/models/wacrm_sync.py` | Modelos cache WACRM |
|
||||||
|
| `odoo-addons/skeen_monedero/models/monedero.py` | Sistema de puntos |
|
||||||
|
| `infra/odoo.conf` | Configuración Odoo |
|
||||||
|
|
||||||
|
### 2.3 WACRM
|
||||||
|
|
||||||
|
| Archivo | Propósito |
|
||||||
|
|---------|-----------|
|
||||||
|
| `wacrm/.env.local` | Variables de entorno (no commitear) |
|
||||||
|
| `wacrm/.env.local.example` | Template de variables |
|
||||||
|
| `wacrm/src/app/api/` | API routes de WACRM |
|
||||||
|
|
||||||
|
### 2.4 Hermes
|
||||||
|
|
||||||
|
| Archivo | Propósito |
|
||||||
|
|---------|-----------|
|
||||||
|
| `hermes/hermes/webhook-bridge.js` | Bridge WACRM → Hermes |
|
||||||
|
| `hermes/hermes/skills/skeen-*/SKILL.md` | Definición de skills |
|
||||||
|
| `/root/.hermes/skills/skeen-services.json` | Catálogo de servicios (generado) |
|
||||||
|
|
||||||
|
### 2.5 Migración
|
||||||
|
|
||||||
|
| Archivo | Propósito |
|
||||||
|
|---------|-----------|
|
||||||
|
| `migracion/importar_a_odoo.py` | Migración principal |
|
||||||
|
| `migracion/importar_servicios_inventario.py` | Servicios e inventario |
|
||||||
|
| `migracion/extraer_skeen.py` | Extracción legacy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Flujo de Archivos
|
||||||
|
|
||||||
|
### 3.1 Agendar Cita (Frontend → Odoo)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/pages/Agenda.tsx
|
||||||
|
→ frontend/src/services/odoo.ts (createAppointment)
|
||||||
|
→ POST /skeen/frontend/v1/appointments
|
||||||
|
→ odoo-addons/skeen_whatsapp/controllers/frontend.py (create_appointment)
|
||||||
|
→ odoo-addons/skeen_citas/models/cita.py (create)
|
||||||
|
→ PostgreSQL skeen_odoo
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Mensaje WhatsApp (Meta → WACRM → Hermes → Odoo)
|
||||||
|
|
||||||
|
```
|
||||||
|
Meta Cloud API
|
||||||
|
→ POST /api/whatsapp/webhook (WACRM)
|
||||||
|
→ wacrm/src/app/api/whatsapp/webhook/route.ts
|
||||||
|
→ Supabase (guarda mensaje)
|
||||||
|
→ Automatización send_webhook
|
||||||
|
→ POST http://localhost:8090/webhook/wacrm (Bridge)
|
||||||
|
→ hermes/hermes/webhook-bridge.js
|
||||||
|
→ Nan Builders API (Qwen3.6)
|
||||||
|
→ Respuesta
|
||||||
|
→ POST /api/v1/messages (WACRM)
|
||||||
|
→ Meta Cloud API
|
||||||
|
→ Paciente
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Consulta de Servicios (Hermes → Odoo)
|
||||||
|
|
||||||
|
```
|
||||||
|
Usuario: "¿Cuánto cuesta la depilación láser?"
|
||||||
|
→ WACRM → Bridge → Hermes
|
||||||
|
→ Skill skeen-rag activado
|
||||||
|
→ Lee /root/.hermes/skills/skeen-services.json
|
||||||
|
→ Responde con precios y paquetes
|
||||||
|
→ Si quiere agendar → Skill skeen-agendar
|
||||||
|
→ POST /skeen/api/v1/available_slots (Odoo)
|
||||||
|
→ POST /skeen/api/v1/create_appointment (Odoo)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Archivos No Incluidos en Git
|
||||||
|
|
||||||
|
Los siguientes archivos/directorios están en `.gitignore` y no se suben al repositorio:
|
||||||
|
|
||||||
|
| Archivo/Directorio | Razón |
|
||||||
|
|--------------------|-------|
|
||||||
|
| `.env`, `.env.local` | Secretos |
|
||||||
|
| `node_modules/` | Dependencias |
|
||||||
|
| `dist/`, `build/` | Builds |
|
||||||
|
| `__pycache__/`, `*.pyc` | Cache Python |
|
||||||
|
| `backups/` | Backups |
|
||||||
|
| `datos/` | Datos sensibles de migración |
|
||||||
|
| `*.key`, `*.pem` | Certificados |
|
||||||
|
| `hermes-venv/`, `odoo-venv/` | Entornos virtuales |
|
||||||
|
| `.next/` | Cache Next.js |
|
||||||
|
| `*.log` | Logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ramas de Git
|
||||||
|
|
||||||
|
| Rama | Propósito |
|
||||||
|
|------|-----------|
|
||||||
|
| `main` | Producción estable |
|
||||||
|
| `develop` | Desarrollo integrado |
|
||||||
|
| `feature/*` | Nuevas funcionalidades |
|
||||||
|
| `fix/*` | Correcciones de bugs |
|
||||||
|
| `hotfix/*` | Correcciones urgentes para producción |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Convenciones de Código
|
||||||
|
|
||||||
|
### 6.1 Python (Odoo)
|
||||||
|
|
||||||
|
- PEP 8
|
||||||
|
- Docstrings en español
|
||||||
|
- Nombres de modelos: `skeen.nombre_modelo`
|
||||||
|
- Nombres de campos: snake_case
|
||||||
|
|
||||||
|
### 6.2 TypeScript (Frontend)
|
||||||
|
|
||||||
|
- ESLint + Prettier
|
||||||
|
- Nombres de componentes: PascalCase
|
||||||
|
- Nombres de archivos: kebab-case o PascalCase según tipo
|
||||||
|
- Interfaces: PascalCase con prefijo `I` o sin prefijo según contexto
|
||||||
|
|
||||||
|
### 6.3 JavaScript (Bridge)
|
||||||
|
|
||||||
|
- Node.js estándar
|
||||||
|
- async/await
|
||||||
|
- Nombres de variables: camelCase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dependencias Principales
|
||||||
|
|
||||||
|
### 7.1 Frontend
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"recharts": "^2.10.0",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Bridge
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"node-fetch": "^3.3.0",
|
||||||
|
"crypto": "^1.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Odoo
|
||||||
|
|
||||||
|
```
|
||||||
|
odoo==17.0
|
||||||
|
psycopg2-binary
|
||||||
|
python-jose
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Puertos Utilizados
|
||||||
|
|
||||||
|
| Puerto | Servicio | Protocolo |
|
||||||
|
|--------|----------|-----------|
|
||||||
|
| 22 | SSH | TCP |
|
||||||
|
| 80 | HTTP (nginx) | TCP |
|
||||||
|
| 443 | HTTPS (nginx) | TCP |
|
||||||
|
| 3000 | WACRM | HTTP |
|
||||||
|
| 3001 | Uptime Kuma | HTTP |
|
||||||
|
| 5173 | Frontend dev | HTTP |
|
||||||
|
| 5432 | PostgreSQL (host) | TCP |
|
||||||
|
| 5433 | PostgreSQL (Supabase) | TCP |
|
||||||
|
| 8000 | Supabase Kong | HTTP |
|
||||||
|
| 8069 | Odoo | HTTP |
|
||||||
|
| 8072 | Odoo longpolling | HTTP |
|
||||||
|
| 8080 | Hermes Gateway | HTTP |
|
||||||
|
| 8090 | Bridge SKEEN | HTTP |
|
||||||
|
| 9119 | Hermes Serve | HTTP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Volúmenes Docker
|
||||||
|
|
||||||
|
| Volumen | Propósito |
|
||||||
|
|---------|-----------|
|
||||||
|
| `supabase-db-data` | Datos PostgreSQL Supabase |
|
||||||
|
| `supabase-storage` | Archivos Supabase |
|
||||||
|
| `supabase-kong` | Configuración Kong |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Notas de Despliegue
|
||||||
|
|
||||||
|
1. **Frontend:** Build estático desplegado en nginx.
|
||||||
|
2. **WACRM:** Build Next.js corriendo con `npm start`.
|
||||||
|
3. **Odoo:** Proceso Python corriendo con `odoo-bin`.
|
||||||
|
4. **Supabase:** Contenedores Docker.
|
||||||
|
5. **Hermes:** Procesos Python (`gateway run`, `serve`).
|
||||||
|
6. **Bridge:** Proceso Node.js.
|
||||||
|
|
||||||
|
Todos los servicios deben iniciarse automáticamente en producción usando systemd.
|
||||||
420
docs/instalacion.md
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
# Guía de Instalación — SKEEN Derma Experts
|
||||||
|
|
||||||
|
**Versión:** 2.0
|
||||||
|
**Fecha:** 16 de julio de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Requisitos del Sistema
|
||||||
|
|
||||||
|
### Hardware Mínimo
|
||||||
|
|
||||||
|
- **CPU:** 4 vCPU
|
||||||
|
- **RAM:** 16 GB (22 GB recomendado)
|
||||||
|
- **Disco:** 80 GB SSD
|
||||||
|
- **Red:** IP pública fija (para webhooks de Meta)
|
||||||
|
|
||||||
|
### Software
|
||||||
|
|
||||||
|
| Software | Versión | Propósito |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| Ubuntu | 22.04 LTS | Sistema operativo |
|
||||||
|
| Docker | 24+ | Supabase self-hosted |
|
||||||
|
| Docker Compose | 2.20+ | Orquestación Supabase |
|
||||||
|
| Node.js | 20+ | WACRM, Bridge, Frontend build |
|
||||||
|
| Python | 3.12+ | Odoo, migración |
|
||||||
|
| PostgreSQL | 16 | Odoo DB |
|
||||||
|
| nginx | 1.24+ | Reverse proxy, static files |
|
||||||
|
| git | 2.40+ | Control de versiones |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Instalación del Sistema Base
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Actualizar sistema
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Instalar dependencias base
|
||||||
|
sudo apt install -y \
|
||||||
|
curl wget git nginx postgresql postgresql-contrib \
|
||||||
|
build-essential libssl-dev libffi-dev libxml2-dev libxslt1-dev \
|
||||||
|
libjpeg-dev libpq-dev python3 python3-pip python3-venv \
|
||||||
|
python3-dev libldap2-dev libsasl2-dev
|
||||||
|
|
||||||
|
# Instalar Node.js 20
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
|
||||||
|
# Instalar Docker
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
newgrp docker
|
||||||
|
|
||||||
|
# Instalar Docker Compose
|
||||||
|
sudo apt install -y docker-compose-plugin
|
||||||
|
|
||||||
|
# Verificar instalaciones
|
||||||
|
node -v # v20.x
|
||||||
|
python3 --version # 3.12.x
|
||||||
|
docker --version
|
||||||
|
docker compose version
|
||||||
|
psql --version
|
||||||
|
nginx -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuración de PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Crear usuario y base de datos para Odoo
|
||||||
|
sudo -u postgres psql << 'EOF'
|
||||||
|
CREATE USER skeen WITH PASSWORD 'skeen_dev_2026';
|
||||||
|
ALTER USER skeen CREATEDB;
|
||||||
|
CREATE DATABASE skeen_odoo OWNER skeen;
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Instalación de Supabase Self-Hosted
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clonar Supabase
|
||||||
|
cd /root
|
||||||
|
git clone https://github.com/supabase/supabase.git skeen-supabase
|
||||||
|
cd skeen-supabase/docker
|
||||||
|
|
||||||
|
# Configurar variables de entorno
|
||||||
|
cp .env.example .env
|
||||||
|
# Editar .env con tus valores
|
||||||
|
|
||||||
|
# Levantar Supabase
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Verificar contenedores
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f db
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variables importantes en `.env`:**
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=your_postgres_password
|
||||||
|
JWT_SECRET=your_jwt_secret_32_chars
|
||||||
|
ANON_KEY=your_anon_jwt
|
||||||
|
SERVICE_ROLE_KEY=your_service_role_jwt
|
||||||
|
DASHBOARD_USERNAME=supabase
|
||||||
|
DASHBOARD_PASSWORD=your_dashboard_password
|
||||||
|
SITE_URL=http://localhost:3000
|
||||||
|
ADDITIONAL_REDIRECT_URLS=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Instalación de WACRM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root
|
||||||
|
git clone https://github.com/ArnasDon/wacrm.git skeen-wacrm
|
||||||
|
cd skeen-wacrm
|
||||||
|
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Configurar entorno
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
**Editar `.env.local`:**
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Supabase (self-hosted on localhost)
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
||||||
|
|
||||||
|
# Encryption key for WACRM (64 hex chars)
|
||||||
|
ENCRYPTION_KEY=your_64_hex_char_key
|
||||||
|
|
||||||
|
# WhatsApp / Meta (pending client data)
|
||||||
|
WHATSAPP_PHONE_NUMBER_ID=PENDING_PHONE_ID
|
||||||
|
WHATSAPP_BUSINESS_ACCOUNT_ID=PENDING_WABA_ID
|
||||||
|
WHATSAPP_ACCESS_TOKEN=PENDING_TOKEN
|
||||||
|
WHATSAPP_VERIFY_TOKEN=skeen_webhook_verify_2026
|
||||||
|
|
||||||
|
# Meta App Secret for webhook signature verification
|
||||||
|
META_APP_SECRET=your_meta_app_secret
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejecutar migraciones de base de datos:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ejecutar migraciones SQL en Supabase
|
||||||
|
# Las migraciones 026_api_keys.sql y 028_webhook_endpoints.sql son REQUERIDAS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build y arrancar:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
# Dev: npm run dev (localhost:3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Instalación de Odoo 17
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root
|
||||||
|
git clone https://github.com/odoo/odoo.git --depth 1 --branch 17.0 skeen-odoo
|
||||||
|
cd skeen-odoo
|
||||||
|
|
||||||
|
# Crear entorno virtual
|
||||||
|
python3 -m venv /root/odoo-venv
|
||||||
|
source /root/odoo-venv/bin/activate
|
||||||
|
|
||||||
|
# Instalar dependencias
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Configurar odoo.conf
|
||||||
|
cat > odoo.conf << 'EOF'
|
||||||
|
[options]
|
||||||
|
addons_path = /root/skeen-odoo/addons,/root/skeen-odoo/odoo/addons
|
||||||
|
admin_passwd = skeen_admin_2026
|
||||||
|
db_host = localhost
|
||||||
|
db_name = skeen_odoo
|
||||||
|
db_password = skeen_dev_2026
|
||||||
|
db_port = 5432
|
||||||
|
db_user = skeen
|
||||||
|
http_port = 8069
|
||||||
|
proxy_mode = True
|
||||||
|
workers = 0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Copiar módulos custom
|
||||||
|
cp -r /root/SKEEN-Proyecto/odoo-addons/* /root/skeen-odoo/addons/
|
||||||
|
|
||||||
|
# Iniciar Odoo
|
||||||
|
./odoo-bin -c odoo.conf --logfile=/tmp/odoo.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Instalación de Hermes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root
|
||||||
|
git clone https://github.com/NousResearch/Hermes-Agent.git hermes-repo
|
||||||
|
cd hermes-repo
|
||||||
|
|
||||||
|
# Crear entorno virtual
|
||||||
|
python3 -m venv /root/hermes-venv
|
||||||
|
source /root/hermes-venv/bin/activate
|
||||||
|
|
||||||
|
# Instalar
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Configurar
|
||||||
|
hermes setup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configurar variables de entorno:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NAN_API_KEY=sk-gJ8OWbZVGeWy9ns_H00cxA
|
||||||
|
export NAN_BASE_URL=https://api.nan.builders/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Iniciar gateway:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Instalación del Bridge SKEEN
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/skeen-roadmap-hermes/hermes
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Configurar
|
||||||
|
cat > .env << 'EOF'
|
||||||
|
NAN_API_KEY=sk-gJ8OWbZVGeWy9ns_H00cxA
|
||||||
|
NAN_BASE_URL=https://api.nan.builders/v1
|
||||||
|
NAN_MODEL=qwen3.6
|
||||||
|
WACRM_URL=http://localhost:3000
|
||||||
|
WACRM_API_KEY=your_wacrm_api_key
|
||||||
|
BRIDGE_PORT=8090
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Iniciar bridge
|
||||||
|
node webhook-bridge.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Instalación del Frontend React
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root
|
||||||
|
git clone https://git.consultoria-as.com/consultoria-as/SKEEN-Proyecto.git
|
||||||
|
cd SKEEN-Proyecto/frontend
|
||||||
|
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Desarrollo
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Producción
|
||||||
|
npm run build
|
||||||
|
sudo mkdir -p /var/www/skeen-frontend
|
||||||
|
sudo cp -r dist/* /var/www/skeen-frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configurar nginx:**
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name skeen.consultoria-as.com;
|
||||||
|
root /var/www/skeen-frontend;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/odoo {
|
||||||
|
proxy_pass http://localhost:8069;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/wacrm {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Configuración de Automatización en WACRM
|
||||||
|
|
||||||
|
1. Ir a **WACRM Dashboard → Automations**.
|
||||||
|
2. Crear automatización "Nuevo mensaje → Webhook Hermes Bridge":
|
||||||
|
- **Trigger:** `new_message_received`
|
||||||
|
- **Step:** `send_webhook`
|
||||||
|
- **URL:** `http://localhost:8090/webhook/wacrm`
|
||||||
|
- **Body:** `{"from":"{{ vars.contact_phone }}","text":"{{ message.text }}","conversation_id":"{{ vars.conversation_id }}","contact_id":"{{ vars.contact_id }}"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Creación de API Key en WACRM
|
||||||
|
|
||||||
|
1. Ir a **WACRM Dashboard → Settings → API Keys**.
|
||||||
|
2. Crear nueva API key con scopes:
|
||||||
|
- `messages:send`
|
||||||
|
- `messages:read`
|
||||||
|
- `contacts:read`
|
||||||
|
- `contacts:write`
|
||||||
|
- `conversations:read`
|
||||||
|
- `broadcasts:send`
|
||||||
|
- `webhooks:manage`
|
||||||
|
3. Guardar la key en `.env` del bridge como `WACRM_API_KEY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Verificación de la Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health checks
|
||||||
|
curl http://localhost:8069/web/health
|
||||||
|
curl http://localhost:8090/health
|
||||||
|
curl http://localhost:8069/skeen/frontend/v1/health
|
||||||
|
|
||||||
|
# Servicios corriendo
|
||||||
|
ss -tlnp | grep -E "3000|5173|8069|8000|8090|8644|8080|9119"
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
tail -f /tmp/wacrm.log
|
||||||
|
tail -f /tmp/odoo.log
|
||||||
|
tail -f /tmp/hermes-bridge.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Troubleshooting Común
|
||||||
|
|
||||||
|
### WACRM no conecta a Supabase
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar contenedores
|
||||||
|
cd /root/skeen-supabase/docker && docker compose ps
|
||||||
|
|
||||||
|
# Verificar variables de entorno
|
||||||
|
cat /root/skeen-wacrm/.env.local | grep SUPABASE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Odoo no inicia
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver logs
|
||||||
|
tail -100 /tmp/odoo.log
|
||||||
|
|
||||||
|
# Verificar PostgreSQL
|
||||||
|
sudo -u postgres psql -d skeen_odoo -c "SELECT 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bridge no responde
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar puerto
|
||||||
|
ss -tlnp | grep 8090
|
||||||
|
|
||||||
|
# Ver logs
|
||||||
|
tail -100 /tmp/hermes-bridge.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend no carga
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar nginx
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl status nginx
|
||||||
|
|
||||||
|
# Verificar build
|
||||||
|
ls -la /var/www/skeen-frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Scripts de Operación
|
||||||
|
|
||||||
|
| Script | Propósito |
|
||||||
|
|--------|-----------|
|
||||||
|
| `scripts/skeen-start-all.sh` | Inicia todo el ecosistema |
|
||||||
|
| `scripts/skeen-stop-all.sh` | Detiene todo el ecosistema |
|
||||||
|
| `scripts/skeen-start-odoo.sh` | Inicia solo Odoo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Notas de Producción
|
||||||
|
|
||||||
|
1. **HTTPS:** Configurar SSL con Let's Encrypt antes de go-live.
|
||||||
|
2. **Firewall:** Abrir solo puertos necesarios (80, 443, 22).
|
||||||
|
3. **Backups:** Configurar backups diarios de PostgreSQL.
|
||||||
|
4. **Monitoreo:** Instalar Uptime Kuma.
|
||||||
|
5. **Logs:** Configurar logrotate para `/tmp/*.log`.
|
||||||
|
6. **Variables de entorno:** Usar systemd EnvironmentFile o .env files.
|
||||||
398
docs/mantenimiento.md
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
# Mantenimiento y Operación — SKEEN Derma Experts
|
||||||
|
|
||||||
|
**Versión:** 2.0
|
||||||
|
**Fecha:** 16 de julio de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Operaciones Diarias
|
||||||
|
|
||||||
|
### 1.1 Verificación de Servicios
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Estado de puertos
|
||||||
|
ss -tlnp | grep -E "3000|5173|8069|8000|8090|8644|8080|9119|5433"
|
||||||
|
|
||||||
|
# Health checks
|
||||||
|
curl http://localhost:8069/web/health
|
||||||
|
curl http://localhost:8090/health
|
||||||
|
curl http://localhost:8069/skeen/frontend/v1/health
|
||||||
|
|
||||||
|
# Estado Docker (Supabase)
|
||||||
|
cd /root/skeen-supabase/docker && docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Logs Principales
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WACRM
|
||||||
|
tail -f /tmp/wacrm.log
|
||||||
|
|
||||||
|
# Odoo
|
||||||
|
tail -f /tmp/odoo.log
|
||||||
|
|
||||||
|
# Bridge
|
||||||
|
tail -f /tmp/hermes-bridge.log
|
||||||
|
|
||||||
|
# Hermes Gateway
|
||||||
|
tail -f /tmp/hermes-gateway.log
|
||||||
|
|
||||||
|
# Hermes Serve
|
||||||
|
tail -f /tmp/hermes-serve.log
|
||||||
|
|
||||||
|
# Frontend (dev)
|
||||||
|
tail -f /tmp/frontend.log
|
||||||
|
|
||||||
|
# nginx
|
||||||
|
tail -f /var/log/nginx/access.log
|
||||||
|
tail -f /var/log/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Operaciones Semanales
|
||||||
|
|
||||||
|
### 2.1 Limpieza de Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rotación manual si no hay logrotate
|
||||||
|
mv /tmp/odoo.log /tmp/odoo.log.$(date +%Y%m%d)
|
||||||
|
mv /tmp/wacrm.log /tmp/wacrm.log.$(date +%Y%m%d)
|
||||||
|
mv /tmp/hermes-bridge.log /tmp/hermes-bridge.log.$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# Reiniciar servicios para recrear logs
|
||||||
|
systemctl restart odoo
|
||||||
|
# o reiniciar manualmente según el método de inicio
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Verificación de Espacio en Disco
|
||||||
|
|
||||||
|
```bash
|
||||||
|
df -h
|
||||||
|
du -sh /var/lib/odoo
|
||||||
|
du -sh /root/skeen-supabase/docker/volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Verificación de Memoria
|
||||||
|
|
||||||
|
```bash
|
||||||
|
free -h
|
||||||
|
ps aux --sort=-%mem | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Operaciones Mensuales
|
||||||
|
|
||||||
|
### 3.1 Actualizaciones de Seguridad
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sistema
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Node.js (si hay nueva versión LTS)
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
|
||||||
|
# Dependencias npm (frontend)
|
||||||
|
cd /root/skeen-frontend && npm audit fix
|
||||||
|
|
||||||
|
# Dependencias npm (WACRM)
|
||||||
|
cd /root/skeen-wacrm && npm audit fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Revisión de Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar backups recientes
|
||||||
|
ls -la /root/backups/
|
||||||
|
|
||||||
|
# Probar restauración (en entorno de prueba)
|
||||||
|
gunzip -c /root/backups/odoo_YYYYMMDD.sql.gz | psql -U skeen -d skeen_odoo_test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Revisión de Certificados
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Monitoreo
|
||||||
|
|
||||||
|
### 4.1 Uptime Kuma (a instalar)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar Uptime Kuma
|
||||||
|
docker run -d --restart=always \
|
||||||
|
-p 3001:3001 \
|
||||||
|
-v uptime-kuma:/app/data \
|
||||||
|
--name uptime-kuma \
|
||||||
|
louislam/uptime-kuma:1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Monitores a configurar:**
|
||||||
|
- Frontend React: `http://localhost:5173`
|
||||||
|
- Odoo: `http://localhost:8069/web/health`
|
||||||
|
- WACRM: `http://localhost:3000`
|
||||||
|
- Bridge: `http://localhost:8090/health`
|
||||||
|
- Supabase: `http://localhost:8000`
|
||||||
|
|
||||||
|
**Alertas:**
|
||||||
|
- Email: ialcarazsalazar@consultoria-as.com
|
||||||
|
- WhatsApp: (vía webhook a WACRM)
|
||||||
|
|
||||||
|
### 4.2 Métricas Clave
|
||||||
|
|
||||||
|
| Métrica | Umbral | Acción |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| CPU | > 80% por 5 min | Investigar proceso |
|
||||||
|
| RAM | > 90% | Reiniciar servicios si es necesario |
|
||||||
|
| Disco | > 85% | Limpiar logs/backups |
|
||||||
|
| Response time Odoo | > 5s | Optimizar queries |
|
||||||
|
| Response time WACRM | > 3s | Reiniciar WACRM |
|
||||||
|
| Errores 5xx | > 10/min | Revisar logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Troubleshooting
|
||||||
|
|
||||||
|
### 5.1 WACRM no responde
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar proceso
|
||||||
|
ps aux | grep node | grep wacrm
|
||||||
|
|
||||||
|
# Verificar logs
|
||||||
|
tail -100 /tmp/wacrm.log
|
||||||
|
|
||||||
|
# Reiniciar
|
||||||
|
cd /root/skeen-wacrm && pkill -f "next start" && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Odoo no responde
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar proceso
|
||||||
|
ps aux | grep odoo
|
||||||
|
|
||||||
|
# Verificar PostgreSQL
|
||||||
|
sudo -u postgres psql -d skeen_odoo -c "SELECT 1;"
|
||||||
|
|
||||||
|
# Verificar logs
|
||||||
|
tail -100 /tmp/odoo.log
|
||||||
|
|
||||||
|
# Reiniciar
|
||||||
|
pkill -f "odoo-bin"
|
||||||
|
cd /root/skeen-odoo && ./odoo-bin -c odoo.conf --logfile=/tmp/odoo.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Bridge no responde
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar proceso
|
||||||
|
ps aux | grep webhook-bridge
|
||||||
|
|
||||||
|
# Verificar logs
|
||||||
|
tail -100 /tmp/hermes-bridge.log
|
||||||
|
|
||||||
|
# Reiniciar
|
||||||
|
pkill -f "webhook-bridge.js"
|
||||||
|
cd /root/skeen-roadmap-hermes/hermes && node webhook-bridge.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Supabase no responde
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar contenedores
|
||||||
|
cd /root/skeen-supabase/docker && docker compose ps
|
||||||
|
|
||||||
|
# Ver logs
|
||||||
|
docker compose logs db
|
||||||
|
docker compose logs kong
|
||||||
|
docker compose logs auth
|
||||||
|
|
||||||
|
# Reiniciar
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Frontend no carga
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar nginx
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl status nginx
|
||||||
|
|
||||||
|
# Verificar build
|
||||||
|
ls -la /var/www/skeen-frontend/
|
||||||
|
|
||||||
|
# Rebuild si es necesario
|
||||||
|
cd /root/skeen-frontend && npm run build
|
||||||
|
sudo cp -r dist/* /var/www/skeen-frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 WhatsApp no envía mensajes
|
||||||
|
|
||||||
|
1. Verificar tokens en WACRM Settings.
|
||||||
|
2. Verificar webhook configurado en Meta.
|
||||||
|
3. Verificar templates aprobados.
|
||||||
|
4. Revisar logs de WACRM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Backups y Restauración
|
||||||
|
|
||||||
|
### 6.1 Backup Automatizado
|
||||||
|
|
||||||
|
Crear `/etc/cron.daily/skeen-backup`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR=/root/backups
|
||||||
|
|
||||||
|
# PostgreSQL Odoo
|
||||||
|
pg_dump -U skeen -h localhost skeen_odoo | gzip > $BACKUP_DIR/odoo_$DATE.sql.gz
|
||||||
|
|
||||||
|
# PostgreSQL Supabase
|
||||||
|
docker exec supabase-db pg_dump -U postgres postgres | gzip > $BACKUP_DIR/supabase_$DATE.sql.gz
|
||||||
|
|
||||||
|
# Archivos
|
||||||
|
tar -czf $BACKUP_DIR/files_$DATE.tar.gz \
|
||||||
|
/root/skeen-frontend \
|
||||||
|
/root/skeen-wacrm \
|
||||||
|
/root/skeen-odoo/addons/skeen_* \
|
||||||
|
/root/skeen-roadmap-hermes \
|
||||||
|
/root/migracion
|
||||||
|
|
||||||
|
# Limpiar antiguos
|
||||||
|
find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete
|
||||||
|
find $BACKUP_DIR -name "*.tar.gz" -mtime +30 -delete
|
||||||
|
|
||||||
|
echo "Backup completado: $DATE"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x /etc/cron.daily/skeen-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Restauración Odoo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Detener Odoo
|
||||||
|
pkill -f "odoo-bin"
|
||||||
|
|
||||||
|
# Restaurar base de datos
|
||||||
|
gunzip -c /root/backups/odoo_YYYYMMDD.sql.gz | psql -U skeen -d skeen_odoo
|
||||||
|
|
||||||
|
# Reiniciar Odoo
|
||||||
|
cd /root/skeen-odoo && ./odoo-bin -c odoo.conf --logfile=/tmp/odoo.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Restauración Supabase
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Detener Supabase
|
||||||
|
cd /root/skeen-supabase/docker && docker compose down
|
||||||
|
|
||||||
|
# Restaurar base de datos
|
||||||
|
gunzip -c /root/backups/supabase_YYYYMMDD.sql.gz | docker exec -i supabase-db psql -U postgres -d postgres
|
||||||
|
|
||||||
|
# Reiniciar Supabase
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Actualización del Sistema
|
||||||
|
|
||||||
|
### 7.1 Actualizar Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/skeen-frontend
|
||||||
|
git pull origin main
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
sudo cp -r dist/* /var/www/skeen-frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Actualizar Módulos Odoo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/skeen-odoo
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Actualizar módulos custom
|
||||||
|
/root/odoo-venv/bin/python ./odoo-bin -c odoo.conf -u skeen_citas,skeen_pacientes,skeen_monedero,skeen_pagos,skeen_ventas,skeen_inventario,skeen_whatsapp -d skeen_odoo --stop-after-init --no-http
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Actualizar WACRM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/skeen-wacrm
|
||||||
|
git pull origin main
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pkill -f "next start"
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Escalabilidad
|
||||||
|
|
||||||
|
### 8.1 Cuando el sistema crezca
|
||||||
|
|
||||||
|
| Señal | Acción |
|
||||||
|
|-------|--------|
|
||||||
|
| CPU > 70% constante | Migrar a VPS más grande |
|
||||||
|
| RAM > 80% constante | Aumentar RAM o separar servicios |
|
||||||
|
| Disco > 80% | Aumentar disco o mover backups a externo |
|
||||||
|
| Muchos usuarios concurrentes | Load balancer + múltiples instancias |
|
||||||
|
| Muchas conversaciones WhatsApp | WACRM escalado horizontalmente |
|
||||||
|
|
||||||
|
### 8.2 Separación de Servicios
|
||||||
|
|
||||||
|
Cuando sea necesario:
|
||||||
|
|
||||||
|
1. **VM 1:** Frontend + nginx + Bridge
|
||||||
|
2. **VM 2:** Odoo + PostgreSQL
|
||||||
|
3. **VM 3:** WACRM + Supabase
|
||||||
|
4. **VM 4:** Hermes Gateway + Serve
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Contacto de Soporte
|
||||||
|
|
||||||
|
- **Email:** ialcarazsalazar@consultoria-as.com
|
||||||
|
- **Gitea Issues:** https://git.consultoria-as.com/consultoria-as/SKEEN-Proyecto/issues
|
||||||
|
- **Horario:** Lun-Vie 9:00-18:00 (hora del Pacífico)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Checklist de Mantenimiento
|
||||||
|
|
||||||
|
### Diario
|
||||||
|
- [ ] Verificar servicios corriendo
|
||||||
|
- [ ] Revisar logs de errores
|
||||||
|
- [ ] Verificar backups del día anterior
|
||||||
|
|
||||||
|
### Semanal
|
||||||
|
- [ ] Limpiar logs antiguos
|
||||||
|
- [ ] Verificar espacio en disco
|
||||||
|
- [ ] Verificar uso de memoria
|
||||||
|
- [ ] Probar flujo end-to-end (WhatsApp → Odoo → Frontend)
|
||||||
|
|
||||||
|
### Mensual
|
||||||
|
- [ ] Actualizar sistema y dependencias
|
||||||
|
- [ ] Revisar y probar backups
|
||||||
|
- [ ] Revisar certificados SSL
|
||||||
|
- [ ] Revisar accesos y API keys
|
||||||
|
- [ ] Revisar métricas de uso
|
||||||
|
|
||||||
|
### Trimestral
|
||||||
|
- [ ] Rotar secretos y API keys
|
||||||
|
- [ ] Revisar políticas de seguridad
|
||||||
|
- [ ] Actualizar documentación
|
||||||
|
- [ ] Pruebas de recuperación de desastres
|
||||||
298
docs/migracion.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
# Guía de Migración — SKEEN Derma Experts
|
||||||
|
|
||||||
|
**Versión:** 2.0
|
||||||
|
**Fecha:** 16 de julio de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introducción
|
||||||
|
|
||||||
|
Esta guía describe el proceso de migración de datos desde el sistema legacy de SKEEN (`https://sistema.skeenmx.app/`) hacia el nuevo sistema basado en Odoo 17.
|
||||||
|
|
||||||
|
**Importante:** La migración requiere autorización documentada del cliente. No se ha realizado extracción masiva de datos sin permiso explícito.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Datos a Migrar
|
||||||
|
|
||||||
|
| Entidad | Descripción | Prioridad |
|
||||||
|
|---------|-------------|-----------|
|
||||||
|
| Pacientes | Datos personales, contacto, historial médico | Alta |
|
||||||
|
| Citas | Histórico de citas, fechas, servicios | Alta |
|
||||||
|
| Servicios | Catálogo de tratamientos, precios, paquetes | Alta |
|
||||||
|
| Ventas | Histórico de ventas, pagos, adeudos | Media |
|
||||||
|
| Monedero | Puntos de fidelidad acumulados | Media |
|
||||||
|
| Inventario | Productos y consumibles | Media |
|
||||||
|
| Médicos | Personal de la clínica | Baja |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Formatos de Datos
|
||||||
|
|
||||||
|
### 3.1 CSV de Pacientes
|
||||||
|
|
||||||
|
```csv
|
||||||
|
name,phone,email,birthdate,gender,blood_type,allergies,address,notes
|
||||||
|
Juan Pérez,526641234567,juan@example.com,1990-01-15,male,O+,Penicilina,"Calle 123, Rosarito","Paciente frecuente"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
- `name` (required): Nombre completo
|
||||||
|
- `phone` (required): Teléfono (formato internacional sin +)
|
||||||
|
- `email`: Email
|
||||||
|
- `birthdate`: Fecha de nacimiento (YYYY-MM-DD)
|
||||||
|
- `gender`: `male`, `female`, `other`
|
||||||
|
- `blood_type`: Tipo de sangre
|
||||||
|
- `allergies`: Alergias
|
||||||
|
- `address`: Dirección
|
||||||
|
- `notes`: Notas adicionales
|
||||||
|
|
||||||
|
### 3.2 CSV de Servicios
|
||||||
|
|
||||||
|
```csv
|
||||||
|
name,code,category,price,duration_min,package_price,package_sessions,package_notes,description,service_group,color,is_favorite
|
||||||
|
Consulta Dermatologica,CONS-DERM,consulta,800,30,0,0,,Evaluación completa,Consultas,#1abc9c,true
|
||||||
|
Depilación Laser Full Body,LASER-FUL,tratamiento,2500,90,7000,3,Ahorro de $500,Depilación full body,Corporal,#e74c3c,false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
- `name` (required): Nombre del servicio
|
||||||
|
- `code` (required): Código único
|
||||||
|
- `category`: `consulta`, `tratamiento`, `procedimiento`, `paquete`
|
||||||
|
- `price` (required): Precio por sesión
|
||||||
|
- `duration_min`: Duración en minutos
|
||||||
|
- `package_price`: Precio del paquete (0 si no aplica)
|
||||||
|
- `package_sessions`: Sesiones del paquete (0 si no aplica)
|
||||||
|
- `package_notes`: Notas del paquete
|
||||||
|
- `description`: Descripción
|
||||||
|
- `service_group`: Grupo/categoría
|
||||||
|
- `color`: Color hexadecimal
|
||||||
|
- `is_favorite`: `true`/`false`
|
||||||
|
|
||||||
|
### 3.3 CSV de Inventario
|
||||||
|
|
||||||
|
```csv
|
||||||
|
name,kind,sku,category,unit,qty,qty_optimal,qty_min,cost,expiry_date,notes
|
||||||
|
Suero Vitamina C,producto,PRD-001,Skincare,pieza,24,30,5,320,2027-06-15,Suero antioxidante
|
||||||
|
Guantes Estériles,consumible,CON-003,Instrumental,caja,8,15,3,95,,Caja de 100 unidades
|
||||||
|
```
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
- `name` (required): Nombre del item
|
||||||
|
- `kind`: `producto` o `consumible`
|
||||||
|
- `sku`: Código SKU
|
||||||
|
- `category`: Categoría
|
||||||
|
- `unit`: Unidad de medida
|
||||||
|
- `qty`: Existencia actual
|
||||||
|
- `qty_optimal`: Nivel óptimo
|
||||||
|
- `qty_min`: Stock mínimo
|
||||||
|
- `cost`: Costo unitario
|
||||||
|
- `expiry_date`: Caducidad (YYYY-MM-DD)
|
||||||
|
- `notes`: Notas
|
||||||
|
|
||||||
|
### 3.4 CSV de Citas
|
||||||
|
|
||||||
|
```csv
|
||||||
|
patient_name,patient_phone,service_code,date,time,state,notes
|
||||||
|
Juan Pérez,526641234567,CONS-DERM,2026-07-17,10:00,confirmed,Primera visita
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 CSV de Ventas
|
||||||
|
|
||||||
|
```csv
|
||||||
|
patient_phone,date,service_code,qty,price,payment_method,notes
|
||||||
|
526641234567,2026-07-17,CONS-DERM,1,800,cash,Pago en efectivo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Proceso de Migración
|
||||||
|
|
||||||
|
### 4.1 Preparación
|
||||||
|
|
||||||
|
1. **Obtener autorización:** Documento firmado por SKEEN autorizando la extracción.
|
||||||
|
2. **Exportar datos:** Del sistema legacy (CSV, SQL dump, o API).
|
||||||
|
3. **Limpiar datos:** Verificar formatos, eliminar duplicados.
|
||||||
|
4. **Backup:** Respaldar base de datos actual de Odoo.
|
||||||
|
|
||||||
|
### 4.2 Ejecución
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Colocar CSVs en el directorio de migración
|
||||||
|
cd /root/migracion
|
||||||
|
mkdir -p datos
|
||||||
|
cp /ruta/a/csvs/* datos/
|
||||||
|
|
||||||
|
# 2. Ejecutar migración principal (pacientes, servicios, citas, ventas)
|
||||||
|
python3 importar_a_odoo.py
|
||||||
|
|
||||||
|
# 3. Importar servicios e inventario específicos
|
||||||
|
python3 importar_servicios_inventario.py \
|
||||||
|
--servicios datos/servicios.csv \
|
||||||
|
--inventario datos/inventario.csv \
|
||||||
|
--purge-citas \
|
||||||
|
--purge-sql \
|
||||||
|
--generar-hermes
|
||||||
|
|
||||||
|
# 4. Verificar datos importados
|
||||||
|
python3 -c "
|
||||||
|
import xmlrpc.client
|
||||||
|
url='http://localhost:8069'
|
||||||
|
common=xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common')
|
||||||
|
uid=common.authenticate('skeen_odoo','admin','skeen_admin_2026',{})
|
||||||
|
models=xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object')
|
||||||
|
for m in ['res.partner','skeen.servicio','skeen.cita','skeen.inventario.item']:
|
||||||
|
n=models.execute_kw('skeen_odoo',uid,'skeen_admin_2026',m,'search_count',[[]])
|
||||||
|
print(m,n)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Validación
|
||||||
|
|
||||||
|
1. **Conteo de registros:** Comparar totales origen vs destino.
|
||||||
|
2. **Muestra aleatoria:** Verificar 10-20 registros manualmente.
|
||||||
|
3. **Pruebas funcionales:** Agendar cita, consultar saldo, redimir puntos.
|
||||||
|
4. **Reporte de errores:** Documentar registros que fallaron.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Scripts de Migración
|
||||||
|
|
||||||
|
### 5.1 `importar_a_odoo.py`
|
||||||
|
|
||||||
|
Script principal de migración. Importa:
|
||||||
|
|
||||||
|
- Pacientes (res.partner)
|
||||||
|
- Servicios (skeen.servicio)
|
||||||
|
- Citas (skeen.cita)
|
||||||
|
- Ventas (skeen.venta)
|
||||||
|
- Pagos (skeen.pago)
|
||||||
|
- Monederos (skeen.monedero)
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```bash
|
||||||
|
python3 importar_a_odoo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `importar_servicios_inventario.py`
|
||||||
|
|
||||||
|
Script especializado para servicios e inventario.
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```bash
|
||||||
|
python3 importar_servicios_inventario.py \
|
||||||
|
--servicios servicios.csv \
|
||||||
|
--inventario inventario.csv \
|
||||||
|
[--purge-citas] \
|
||||||
|
[--purge-sql] \
|
||||||
|
[--generar-hermes]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opciones:**
|
||||||
|
- `--purge-citas`: Eliminar citas existentes antes de importar
|
||||||
|
- `--purge-sql`: Usar TRUNCATE SQL para limpieza rápida
|
||||||
|
- `--generar-hermes`: Generar catálogo JSON para skills de Hermes
|
||||||
|
|
||||||
|
### 5.3 `extraer_skeen.py`
|
||||||
|
|
||||||
|
Script de extracción del sistema legacy. Requiere credenciales y autorización.
|
||||||
|
|
||||||
|
**Nota:** Este script está disponible pero no se ha ejecutado por restricciones de autorización.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Limpieza de Datos Demo
|
||||||
|
|
||||||
|
Antes de migrar datos reales, se recomienda limpiar datos demo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/migracion
|
||||||
|
|
||||||
|
# Opción 1: Script de borrado
|
||||||
|
python3 borrar_datos_demo.py
|
||||||
|
|
||||||
|
# Opción 2: SQL directo (más rápido)
|
||||||
|
python3 importar_servicios_inventario.py --purge-citas --purge-sql --servicios /dev/null --inventario /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Sincronización Post-Migración
|
||||||
|
|
||||||
|
Después de la migración, es necesario sincronizar con WACRM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desde el frontend o vía API
|
||||||
|
curl -X POST http://localhost:8069/skeen/frontend/v1/wacrm/sync \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"sync_contacts":true,"sync_conversations":true,"sync_messages":true,"sync_deals":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Regeneración del Catálogo Hermes
|
||||||
|
|
||||||
|
Después de importar servicios, regenerar el catálogo para Hermes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/migracion
|
||||||
|
python3 importar_servicios_inventario.py --solo-hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto actualiza:
|
||||||
|
- `/root/.hermes/skills/skeen-services.json`
|
||||||
|
- `/root/skeen-roadmap-hermes/hermes/skills/skeen-services.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Problemas Comunes
|
||||||
|
|
||||||
|
### 9.1 Registros duplicados
|
||||||
|
|
||||||
|
**Problema:** Al re-ejecutar la migración, se crean duplicados.
|
||||||
|
|
||||||
|
**Solución:** Usar `--purge-citas --purge-sql` antes de importar.
|
||||||
|
|
||||||
|
### 9.2 Timeout en importación masiva
|
||||||
|
|
||||||
|
**Problema:** Miles de registros tardan demasiado.
|
||||||
|
|
||||||
|
**Solución:** Usar `--purge-sql` para limpieza rápida, y considerar dividir en lotes.
|
||||||
|
|
||||||
|
### 9.3 Campos faltantes en CSV
|
||||||
|
|
||||||
|
**Problema:** Algunos CSVs no tienen todos los campos.
|
||||||
|
|
||||||
|
**Solución:** Los scripts tienen valores por defecto. Revisar los warnings en la salida.
|
||||||
|
|
||||||
|
### 9.4 Teléfonos con formato incorrecto
|
||||||
|
|
||||||
|
**Problema:** Números de teléfono con formato local.
|
||||||
|
|
||||||
|
**Solución:** Normalizar a formato internacional sin `+` (ej. `526641234567`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Checklist de Migración
|
||||||
|
|
||||||
|
- [ ] Autorización documentada del cliente
|
||||||
|
- [ ] Exportación de datos del sistema legacy
|
||||||
|
- [ ] Backup de base de datos Odoo actual
|
||||||
|
- [ ] Limpieza de datos demo
|
||||||
|
- [ ] Ejecución de scripts de migración
|
||||||
|
- [ ] Validación de conteos
|
||||||
|
- [ ] Validación de muestra aleatoria
|
||||||
|
- [ ] Pruebas funcionales
|
||||||
|
- [ ] Sincronización WACRM
|
||||||
|
- [ ] Regeneración catálogo Hermes
|
||||||
|
- [ ] Reporte de errores y resolución
|
||||||
|
- [ ] Aprobación final del cliente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Contacto
|
||||||
|
|
||||||
|
Para dudas sobre migración:
|
||||||
|
- **Email:** ialcarazsalazar@consultoria-as.com
|
||||||
|
- **Gitea:** https://git.consultoria-as.com/consultoria-as/SKEEN-Proyecto
|
||||||
327
docs/seguridad.md
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
# Seguridad — SKEEN Derma Experts
|
||||||
|
|
||||||
|
**Versión:** 2.0
|
||||||
|
**Fecha:** 16 de julio de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Principios de Seguridad
|
||||||
|
|
||||||
|
1. **Defensa en profundidad:** Múltiples capas de seguridad.
|
||||||
|
2. **Principio de menor privilegio:** Acceso mínimo necesario.
|
||||||
|
3. **Datos en tránsito cifrados:** HTTPS/TLS en producción.
|
||||||
|
4. **Datos en reposo protegidos:** PostgreSQL con autenticación, backups cifrados.
|
||||||
|
5. **Secretos fuera del código:** Variables de entorno, nunca hardcoded.
|
||||||
|
6. **Auditoría:** Logs de acceso y cambios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Autenticación y Autorización
|
||||||
|
|
||||||
|
### 2.1 Frontend React (JWT)
|
||||||
|
|
||||||
|
- **Algoritmo:** HMAC-SHA256
|
||||||
|
- **Expiración:** 24 horas
|
||||||
|
- **Payload:** `uid`, `login`, `role`, `exp`
|
||||||
|
- **Roles:** `admin`, `recepcion`, `medico`, `lectura`
|
||||||
|
|
||||||
|
**Generación de token:**
|
||||||
|
```python
|
||||||
|
def sign_token(env, user):
|
||||||
|
payload = {
|
||||||
|
'uid': user.id,
|
||||||
|
'login': user.login,
|
||||||
|
'role': user.role,
|
||||||
|
'exp': int(time.time()) + 86400
|
||||||
|
}
|
||||||
|
secret = env['ir.config_parameter'].sudo().get_param('skeen.frontend.jwt_secret')
|
||||||
|
return jwt.encode(payload, secret, algorithm='HS256')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Odoo API (API Key)
|
||||||
|
|
||||||
|
- Endpoints JSON-RPC/XML-RPC usan `auth='api_key'`.
|
||||||
|
- API key almacenada en `ir.config_parameter` o header.
|
||||||
|
|
||||||
|
### 2.3 WACRM API (Bearer Token)
|
||||||
|
|
||||||
|
- API keys creadas en Supabase tabla `api_keys`.
|
||||||
|
- Scopes granulares: `messages:send`, `contacts:read`, etc.
|
||||||
|
|
||||||
|
### 2.4 Supabase (JWT)
|
||||||
|
|
||||||
|
- **Anon Key:** Para operaciones públicas.
|
||||||
|
- **Service Role Key:** Para operaciones administrativas (solo backend).
|
||||||
|
- **Row-Level Security (RLS):** Habilitado en tablas sensibles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Webhooks
|
||||||
|
|
||||||
|
### 3.1 Meta → WACRM
|
||||||
|
|
||||||
|
- **Verify Token:** `skeen_webhook_verify_2026`
|
||||||
|
- **Validación:** GET con `hub.mode`, `hub.verify_token`, `hub.challenge`.
|
||||||
|
|
||||||
|
### 3.2 WACRM → Bridge (HMAC-SHA256)
|
||||||
|
|
||||||
|
**Firma:**
|
||||||
|
```javascript
|
||||||
|
function verifySignature(payload, signature) {
|
||||||
|
const [t, v1] = signature.split(',').map(s => s.split('=')[1]);
|
||||||
|
const expected = crypto.createHmac('sha256', WACRM_WEBHOOK_SECRET)
|
||||||
|
.update(`${t}.${payload}`).digest('hex');
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
- `X-WACRM-Signature: t=<timestamp>,v1=<hmac_sha256>`
|
||||||
|
|
||||||
|
**Validación:**
|
||||||
|
1. Extraer timestamp y firma del header.
|
||||||
|
2. Calcular HMAC-SHA256 de `timestamp.payload`.
|
||||||
|
3. Comparar con firma recibida (timing-safe).
|
||||||
|
4. Rechazar si timestamp es muy antiguo (> 5 min).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Protección de Datos
|
||||||
|
|
||||||
|
### 4.1 Datos Sensibles
|
||||||
|
|
||||||
|
| Dato | Protección |
|
||||||
|
|------|-----------|
|
||||||
|
| Teléfonos | RLS en Supabase, validación en Odoo |
|
||||||
|
| Emails | RLS, enmascaramiento en logs |
|
||||||
|
| Historial médico | Acceso restringido por rol |
|
||||||
|
| Datos de pago | Nunca almacenar tarjetas, usar tokens |
|
||||||
|
| API keys | Variables de entorno, nunca commiteadas |
|
||||||
|
|
||||||
|
### 4.2 Enmascaramiento en Logs
|
||||||
|
|
||||||
|
```python
|
||||||
|
def mask_phone(phone):
|
||||||
|
if len(phone) > 4:
|
||||||
|
return phone[:3] + '*' * (len(phone) - 7) + phone[-4:]
|
||||||
|
return '***'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Seguridad en Red
|
||||||
|
|
||||||
|
### 5.1 Firewall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Solo puertos necesarios
|
||||||
|
sudo ufw allow 22/tcp # SSH
|
||||||
|
sudo ufw allow 80/tcp # HTTP
|
||||||
|
sudo ufw allow 443/tcp # HTTPS
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Rate limiting
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
|
||||||
|
server {
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
proxy_pass http://localhost:8069;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 HTTPS
|
||||||
|
|
||||||
|
Configurar SSL en producción:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d skeen.consultoria-as.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Gestión de Secretos
|
||||||
|
|
||||||
|
### 6.1 Variables de Entorno
|
||||||
|
|
||||||
|
**Nunca commitear:**
|
||||||
|
- `.env`
|
||||||
|
- `.env.local`
|
||||||
|
- `*.key`
|
||||||
|
- `*.pem`
|
||||||
|
- Tokens de Meta
|
||||||
|
- Keys de Stripe/MercadoPago
|
||||||
|
|
||||||
|
### 6.2 .gitignore
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# Secretos
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.crt
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Rotación de Secretos
|
||||||
|
|
||||||
|
| Secreto | Frecuencia | Método |
|
||||||
|
|---------|-----------|--------|
|
||||||
|
| Meta Access Token | 60 días | Meta Developers |
|
||||||
|
| WACRM API Key | 90 días | Dashboard WACRM |
|
||||||
|
| Supabase JWT | 180 días | Regenerar |
|
||||||
|
| Odoo admin password | 90 días | Cambiar manual |
|
||||||
|
| ENCRYPTION_KEY | 365 días | Regenerar (requiere re-encrypt) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Auditoría y Logs
|
||||||
|
|
||||||
|
### 7.1 Logs de Odoo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /tmp/odoo.log | grep "audit"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Logs de WACRM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /tmp/wacrm.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Logs del Bridge
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /tmp/hermes-bridge.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 Auditoría de Base de Datos
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Cambios en pacientes
|
||||||
|
SELECT * FROM audit_log WHERE model = 'res.partner' ORDER BY create_date DESC LIMIT 100;
|
||||||
|
|
||||||
|
-- Accesos a datos sensibles
|
||||||
|
SELECT * FROM audit_log WHERE model = 'skeen.cita' AND method = 'read' ORDER BY create_date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Backups
|
||||||
|
|
||||||
|
### 8.1 Estrategia
|
||||||
|
|
||||||
|
| Tipo | Frecuencia | Retención | Ubicación |
|
||||||
|
|------|-----------|-----------|-----------|
|
||||||
|
| PostgreSQL Odoo | Diario | 30 días | Local + remoto |
|
||||||
|
| PostgreSQL Supabase | Diario | 30 días | Local + remoto |
|
||||||
|
| Archivos | Semanal | 12 semanas | Local + remoto |
|
||||||
|
| Configuración | Mensual | 12 meses | Git |
|
||||||
|
|
||||||
|
### 8.2 Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# backup-daily.sh
|
||||||
|
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR=/root/backups
|
||||||
|
|
||||||
|
# PostgreSQL Odoo
|
||||||
|
pg_dump -U skeen -h localhost skeen_odoo | gzip > $BACKUP_DIR/odoo_$DATE.sql.gz
|
||||||
|
|
||||||
|
# PostgreSQL Supabase
|
||||||
|
docker exec supabase-db pg_dump -U postgres postgres | gzip > $BACKUP_DIR/supabase_$DATE.sql.gz
|
||||||
|
|
||||||
|
# Archivos
|
||||||
|
tar -czf $BACKUP_DIR/files_$DATE.tar.gz \
|
||||||
|
/root/skeen-frontend \
|
||||||
|
/root/skeen-wacrm \
|
||||||
|
/root/skeen-odoo/addons/skeen_* \
|
||||||
|
/root/skeen-roadmap-hermes \
|
||||||
|
/root/migracion
|
||||||
|
|
||||||
|
# Limpiar backups antiguos
|
||||||
|
find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete
|
||||||
|
find $BACKUP_DIR -name "*.tar.gz" -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Respuesta a Incidentes
|
||||||
|
|
||||||
|
### 9.1 Detección
|
||||||
|
|
||||||
|
- Monitoreo Uptime Kuma.
|
||||||
|
- Alertas de errores en logs.
|
||||||
|
- Alertas de uso anormal de API.
|
||||||
|
|
||||||
|
### 9.2 Contención
|
||||||
|
|
||||||
|
1. Identificar componente afectado.
|
||||||
|
2. Aislar componente (stop service, block IP).
|
||||||
|
3. Evaluar impacto (datos expuestos, usuarios afectados).
|
||||||
|
|
||||||
|
### 9.3 Erradicación
|
||||||
|
|
||||||
|
1. Cerrar vulnerabilidad.
|
||||||
|
2. Cambiar secretos comprometidos.
|
||||||
|
3. Restaurar desde backup limpio si es necesario.
|
||||||
|
|
||||||
|
### 9.4 Recuperación
|
||||||
|
|
||||||
|
1. Restaurar servicios.
|
||||||
|
2. Verificar integridad de datos.
|
||||||
|
3. Monitorear post-incidente.
|
||||||
|
|
||||||
|
### 9.5 Lecciones Aprendidas
|
||||||
|
|
||||||
|
1. Documentar incidente.
|
||||||
|
2. Actualizar procedimientos.
|
||||||
|
3. Mejorar controles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Checklist de Seguridad
|
||||||
|
|
||||||
|
### Pre-Producción
|
||||||
|
|
||||||
|
- [ ] HTTPS configurado
|
||||||
|
- [ ] Firewall configurado
|
||||||
|
- [ ] Secretos rotados
|
||||||
|
- [ ] API keys con scopes mínimos
|
||||||
|
- [ ] RLS habilitado en Supabase
|
||||||
|
- [ ] Rate limiting configurado
|
||||||
|
- [ ] Logs de auditoría activos
|
||||||
|
- [ ] Backups automatizados
|
||||||
|
- [ ] Monitoreo configurado
|
||||||
|
|
||||||
|
### Producción
|
||||||
|
|
||||||
|
- [ ] Revisión mensual de logs
|
||||||
|
- [ ] Rotación trimestral de secretos
|
||||||
|
- [ ] Pruebas de penetración anuales
|
||||||
|
- [ ] Actualizaciones de seguridad mensuales
|
||||||
|
- [ ] Verificación de backups mensual
|
||||||
|
- [ ] Revisión de accesos trimestral
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Contacto de Seguridad
|
||||||
|
|
||||||
|
Para reportar vulnerabilidades:
|
||||||
|
- **Email:** ialcarazsalazar@consultoria-as.com
|
||||||
|
- **PGP:** (a configurar)
|
||||||
|
|
||||||
|
**No divulgar públicamente** hasta que se haya resuelto.
|
||||||
387
docs/skills-hermes.md
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
# Skills de Hermes — SKEEN Derma Experts
|
||||||
|
|
||||||
|
**Versión:** 2.0
|
||||||
|
**Fecha:** 16 de julio de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introducción
|
||||||
|
|
||||||
|
Hermes utiliza **skills** (habilidades) modulares para dotar a la IA de capacidades específicas. Cada skill es un directorio con un archivo `SKILL.md` que define:
|
||||||
|
|
||||||
|
- **Metadata:** Nombre, descripción, versión, requisitos.
|
||||||
|
- **Instrucciones:** Qué hacer y cómo responder.
|
||||||
|
- **Tools:** Herramientas disponibles.
|
||||||
|
- **Triggers:** Palabras clave que activan el skill.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Skills Disponibles
|
||||||
|
|
||||||
|
| Skill | Trigger Principal | Tools | Frecuencia |
|
||||||
|
|-------|-------------------|-------|------------|
|
||||||
|
| `skeen-rag` | "servicios", "tratamientos", "precios" | file_read, memory_read | On-demand |
|
||||||
|
| `skeen-agendar` | "agendar", "cita", "hora", "disponibilidad" | odoo_api_call, wacrm_send, memory_write | On-demand |
|
||||||
|
| `skeen-pagos` | "pagar", "saldo", "deuda", "puntos" | odoo_api_call, wacrm_send, web_fetch | On-demand |
|
||||||
|
| `skeen-monedero` | "puntos", "monedero", "saldo", "redimir" | odoo_api_call, wacrm_send | On-demand |
|
||||||
|
| `skeen-recordatorios` | Heartbeat (30 min) | odoo_api_call, wacrm_send, memory_read | Cada 30 min |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Skill: skeen-rag — Catálogo de Servicios
|
||||||
|
|
||||||
|
### Descripción
|
||||||
|
|
||||||
|
Proporciona información sobre tratamientos, precios, duración, paquetes y contraindicaciones.
|
||||||
|
|
||||||
|
### Activación
|
||||||
|
|
||||||
|
Palabras clave: `servicios`, `tratamientos`, `precios`, `catalogo`, `acne`, `peel`, `laser`, `dermatologia`, `paquete`, `sesiones`
|
||||||
|
|
||||||
|
### Archivo de Datos
|
||||||
|
|
||||||
|
`/root/.hermes/skills/skeen-services.json`
|
||||||
|
|
||||||
|
**Formato:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clinica": {
|
||||||
|
"nombre": "SKEEN Derma Experts",
|
||||||
|
"direccion": "Blvd. Popotla 123, Playas de Rosarito, BC, Mexico",
|
||||||
|
"telefono": "+52 (664) 123-4567",
|
||||||
|
"horario": {
|
||||||
|
"lunes_viernes": "9:00 - 18:00",
|
||||||
|
"sabado": "9:00 - 14:00",
|
||||||
|
"domingo": "Cerrado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servicios": [
|
||||||
|
{
|
||||||
|
"id": "laser-ful",
|
||||||
|
"nombre": "Depilacion Laser Full Body",
|
||||||
|
"categoria": "tratamiento",
|
||||||
|
"precio": 2500.0,
|
||||||
|
"duracion_min": 90,
|
||||||
|
"precio_paquete": 7000.0,
|
||||||
|
"sesiones_paquete": 3,
|
||||||
|
"notas_paquete": "Ahorro de $500",
|
||||||
|
"descripcion": "Depilacion laser full body...",
|
||||||
|
"contraindicaciones": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instrucciones del Skill
|
||||||
|
|
||||||
|
1. Leer el archivo de catálogo.
|
||||||
|
2. Filtrar servicios relevantes según la pregunta.
|
||||||
|
3. Presentar información de forma clara y amigable.
|
||||||
|
4. SIEMPRE incluir: nombre, precio por sesión, duración, descripción.
|
||||||
|
5. Si el servicio tiene paquete, mencionar ahorro.
|
||||||
|
6. NUNCA diagnosticar condiciones médicas.
|
||||||
|
7. Si no encuentra el servicio, ofrecer consulta de valoración.
|
||||||
|
|
||||||
|
### Ejemplo de Respuesta
|
||||||
|
|
||||||
|
```
|
||||||
|
Hola! En SKEEN contamos con estos tratamientos para depilación:
|
||||||
|
|
||||||
|
1. **Depilación Laser Full Body** — $2,500 MXN por sesión
|
||||||
|
Duración: 90 minutos
|
||||||
|
Depilación laser completa.
|
||||||
|
Paquete de 3 sesiones: $7,000 MXN (ahorro de $500)
|
||||||
|
|
||||||
|
2. **Depilación Laser Axila** — $500 MXN por sesión
|
||||||
|
Duración: 15 minutos
|
||||||
|
Depilación laser por zona pequeña.
|
||||||
|
|
||||||
|
¿Te gustaría agendar una consulta de valoración?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Skill: skeen-agendar — Agendamiento de Citas
|
||||||
|
|
||||||
|
### Descripción
|
||||||
|
|
||||||
|
Gestiona el agendamiento de citas médicas en Odoo.
|
||||||
|
|
||||||
|
### Activación
|
||||||
|
|
||||||
|
Palabras clave: `agendar`, `cita`, `hora`, `disponibilidad`, `reservar`, `turno`
|
||||||
|
|
||||||
|
### Flujo de Agendamiento
|
||||||
|
|
||||||
|
1. **Detectar intención:** Paciente quiere agendar.
|
||||||
|
2. **Consultar servicios:** Usar `skeen-rag` para mostrar opciones.
|
||||||
|
3. **Seleccionar servicio:** Paciente elige tratamiento.
|
||||||
|
4. **Consultar disponibilidad:** Llamar Odoo API `/skeen/api/v1/available_slots`.
|
||||||
|
5. **Presentar opciones:** Mostrar fechas/horas disponibles.
|
||||||
|
6. **Confirmar:** Paciente confirma fecha/hora.
|
||||||
|
7. **Crear cita:** Llamar Odoo API `/skeen/api/v1/create_appointment`.
|
||||||
|
8. **Confirmar al paciente:** Enviar mensaje con detalles.
|
||||||
|
|
||||||
|
### Paquetes de Sesiones
|
||||||
|
|
||||||
|
Si el servicio tiene `package_sessions > 0`:
|
||||||
|
|
||||||
|
1. Informar que existe paquete y su precio.
|
||||||
|
2. Preguntar si quiere sesión individual o paquete.
|
||||||
|
3. Si elige paquete:
|
||||||
|
- Crear N citas separadas (cada 7 días).
|
||||||
|
- La primera a la hora elegida.
|
||||||
|
- Las siguientes a las 9:00 AM (reprogramables).
|
||||||
|
|
||||||
|
### Ejemplo de Respuesta
|
||||||
|
|
||||||
|
```
|
||||||
|
Perfecto! Has elegido Depilación Laser Full Body.
|
||||||
|
|
||||||
|
Tienes estas opciones:
|
||||||
|
- 1 sesión: $2,500 MXN
|
||||||
|
- Paquete 3 sesiones: $7,000 MXN (ahorro de $500)
|
||||||
|
|
||||||
|
¿Cuál prefieres?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Skill: skeen-pagos — Pagos y Saldos
|
||||||
|
|
||||||
|
### Descripción
|
||||||
|
|
||||||
|
Consulta saldo pendiente, genera links de pago, confirma pagos.
|
||||||
|
|
||||||
|
### Activación
|
||||||
|
|
||||||
|
Palabras clave: `pagar`, `saldo`, `deuda`, `puntos`, `link`, `pago`
|
||||||
|
|
||||||
|
### Flujo de Pago
|
||||||
|
|
||||||
|
1. **Consultar saldo:** Llamar Odoo API `/skeen/api/v1/patient_balance`.
|
||||||
|
2. **Mostrar saldo:** Informar monto pendiente y puntos disponibles.
|
||||||
|
3. **Generar link:** Crear link de pago (Stripe/MercadoPago).
|
||||||
|
4. **Enviar link:** Via WhatsApp.
|
||||||
|
5. **Confirmar pago:** Webhook de pasarela → Odoo.
|
||||||
|
|
||||||
|
### Ejemplo de Respuesta
|
||||||
|
|
||||||
|
```
|
||||||
|
Tu saldo pendiente es de $1,500 MXN.
|
||||||
|
|
||||||
|
Tienes 450 puntos en tu monedero (equivalente a $450 MXN).
|
||||||
|
|
||||||
|
¿Quieres que te envíe un link de pago? Puedes pagar con tarjeta o MercadoPago.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Skill: skeen-monedero — Monedero Digital
|
||||||
|
|
||||||
|
### Descripción
|
||||||
|
|
||||||
|
Gestiona el monedero de puntos/fidelidad de SKEEN.
|
||||||
|
|
||||||
|
### Reglas
|
||||||
|
|
||||||
|
- **Acumulación:** 1 punto por cada $10 MXN gastados.
|
||||||
|
- **Valor:** 1 punto = $1 MXN.
|
||||||
|
- **Redención:** Máximo 50% del total de la compra.
|
||||||
|
- **Vencimiento:** Puntos vencen a los 12 meses de inactividad.
|
||||||
|
- **No transferibles:** Entre pacientes.
|
||||||
|
- **Acumulación:** Solo en pagos confirmados.
|
||||||
|
|
||||||
|
### Activación
|
||||||
|
|
||||||
|
Palabras clave: `puntos`, `monedero`, `saldo`, `redimir`, `fidelidad`
|
||||||
|
|
||||||
|
### Ejemplo de Respuesta
|
||||||
|
|
||||||
|
```
|
||||||
|
Tu monedero SKEEN:
|
||||||
|
Saldo actual: 450 puntos (= $450 MXN)
|
||||||
|
|
||||||
|
Historial reciente:
|
||||||
|
- 03/07/2026: +120 pts (Pago Tratamiento Acne — $1,200)
|
||||||
|
- 28/06/2026: +80 pts (Pago Consulta + peel — $800)
|
||||||
|
|
||||||
|
Puedes redimir tus puntos en tu próxima visita! Cada 10 puntos = $10 MXN de descuento.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Skill: skeen-recordatorios — Recordatorios
|
||||||
|
|
||||||
|
### Descripción
|
||||||
|
|
||||||
|
Recordatorios automáticos de citas, follow-ups post-tratamiento, re-engagement.
|
||||||
|
|
||||||
|
### Activación
|
||||||
|
|
||||||
|
**Heartbeat:** Cada 30 minutos.
|
||||||
|
|
||||||
|
### Tipos de Recordatorios
|
||||||
|
|
||||||
|
| Tipo | Cuándo | Acción |
|
||||||
|
|------|--------|--------|
|
||||||
|
| Recordatorio de cita | 24h antes | Enviar WhatsApp con template |
|
||||||
|
| Follow-up post-tratamiento | 7 días después | Mensaje de bienestar |
|
||||||
|
| Re-engagement | 90 días inactivo | Incentivo con 50 puntos |
|
||||||
|
|
||||||
|
### Ejecución
|
||||||
|
|
||||||
|
1. Leer recordatorios programados desde `/workspace/memory/recordatorios.md`.
|
||||||
|
2. Buscar entradas vencidas (timestamp <= ahora).
|
||||||
|
3. Enviar via WACRM usando template aprobado.
|
||||||
|
4. Marcar como enviado en memoria.
|
||||||
|
|
||||||
|
### Templates de WhatsApp
|
||||||
|
|
||||||
|
```
|
||||||
|
recordatorio_cita_skeen:
|
||||||
|
"Hola {{1}}! Te recordamos tu cita mañana {{2}} a las {{3}} en SKEEN Derma Experts."
|
||||||
|
|
||||||
|
followup_tratamiento_skeen:
|
||||||
|
"Hola {{1}}! Han pasado {{2}} días desde tu {{3}}. ¿Cómo te sientes?"
|
||||||
|
|
||||||
|
reengage_skeen:
|
||||||
|
"Hola {{1}}! Te extrañamos en SKEEN. Agenda tu próxima visita y recibe 50 puntos."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Personalidad de Sofía
|
||||||
|
|
||||||
|
### Archivo: SOUL.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Sofia — Asistente Virtual de SKEEN Derma Experts
|
||||||
|
|
||||||
|
## Identidad
|
||||||
|
- Nombre: Sofia
|
||||||
|
- Rol: Asistente virtual de SKEEN Derma Experts
|
||||||
|
- Personalidad: Cálida, profesional, empática, eficiente
|
||||||
|
- Idioma: Español (México)
|
||||||
|
- Emoji favorito: 💙✨🌸
|
||||||
|
|
||||||
|
## Tonos de voz
|
||||||
|
- Saludo: "Hola! Soy Sofia de SKEEN 💙"
|
||||||
|
- Confirmación: "Perfecto! Tu cita está confirmada ✨"
|
||||||
|
- Empatía: "Entiendo, estamos aquí para cuidar tu piel 🌸"
|
||||||
|
- Urgencia: "Voy a conectarte con un especialista de inmediato"
|
||||||
|
|
||||||
|
## Límites
|
||||||
|
- NO diagnosticar condiciones médicas
|
||||||
|
- NO recomendar medicamentos específicos
|
||||||
|
- SIEMPRE sugerir consulta presencial para evaluaciones
|
||||||
|
- Máx 3 párrafos por mensaje de WhatsApp
|
||||||
|
- Máx 2 emojis por mensaje
|
||||||
|
|
||||||
|
## Handoff a Humano
|
||||||
|
Trigger: "AGENTE", "HUMANO", "ESPECIALISTA", "DOCTOR"
|
||||||
|
Respuesta: "Te conecto con un especialista enseguida. Espera un momento 💙"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. System Prompt del Bridge
|
||||||
|
|
||||||
|
El bridge (`webhook-bridge.js`) usa un system prompt que define a Sofía:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const SKEEN_SYSTEM_PROMPT = `Eres Sofia, la asistente virtual de SKEEN Derma Experts, una clínica dermatológica en Playas de Rosarito, BC, México.
|
||||||
|
|
||||||
|
PERSONALIDAD:
|
||||||
|
- Cálida, profesional, empática y eficiente.
|
||||||
|
- Hablas español mexicano natural.
|
||||||
|
- Usas emojis moderadamente (máx 2 por mensaje).
|
||||||
|
|
||||||
|
REGLAS ESTRICTAS:
|
||||||
|
1. NUNCA diagnostiques condiciones médicas.
|
||||||
|
2. NUNCA recomiendes medicamentos específicos.
|
||||||
|
3. SIEMPRE sugiere consulta presencial para evaluaciones.
|
||||||
|
4. Máximo 3 párrafos por respuesta.
|
||||||
|
5. Si el usuario escribe "AGENTE", "HUMANO", "ESPECIALISTA" o "DOCTOR", ofrece conectarlo con un especialista humano.
|
||||||
|
|
||||||
|
DATOS DE LA CLÍNICA:
|
||||||
|
- Nombre: SKEEN Derma Experts
|
||||||
|
- Dirección: Blvd. Popotla 123, Playas de Rosarito, BC
|
||||||
|
- Horario: Lun-Vie 9:00-18:00, Sáb 9:00-14:00
|
||||||
|
- Teléfono: +52 (664) 123-4567
|
||||||
|
|
||||||
|
SERVICIOS PRINCIPALES:
|
||||||
|
- Consulta Dermatológica: $800 MXN (30 min)
|
||||||
|
- Tratamiento Facial para Acné: $1,500 MXN (60 min)
|
||||||
|
- Peel Químico: $1,500 MXN (30 min)
|
||||||
|
- Depilación Láser: desde $500 MXN (15 min)
|
||||||
|
- Tratamiento Anti-Edad: $2,500 MXN (75 min)
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
- Si el paciente quiere agendar, pregunta por servicio, fecha y hora.
|
||||||
|
- Si pregunta por servicios, usa la lista de arriba.
|
||||||
|
- Si pregunta por precios, menciona que son aproximados.
|
||||||
|
- Siempre termina con una pregunta o invitación a agendar.`;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Configuración de Skills en Hermes
|
||||||
|
|
||||||
|
### Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Los skills ya están en /root/.hermes/skills/
|
||||||
|
# Verificar instalación
|
||||||
|
hermes skills list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Habilitar/Deshabilitar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes skills enable skeen-rag
|
||||||
|
hermes skills disable skeen-recordatorios
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actualizar Catálogo de Servicios
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/migracion
|
||||||
|
python3 importar_servicios_inventario.py --solo-hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto regenera `/root/.hermes/skills/skeen-services.json` desde Odoo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Troubleshooting
|
||||||
|
|
||||||
|
### Skill no se activa
|
||||||
|
|
||||||
|
1. Verificar que el skill esté enabled: `hermes skills list`
|
||||||
|
2. Verificar palabras clave en `SKILL.md` metadata.
|
||||||
|
3. Verificar que el archivo JSON de datos exista.
|
||||||
|
|
||||||
|
### Respuestas incorrectas
|
||||||
|
|
||||||
|
1. Revisar system prompt en `webhook-bridge.js`.
|
||||||
|
2. Revisar `SOUL.md` y `AGENTS.md`.
|
||||||
|
3. Verificar que el modelo sea correcto (Qwen3.6).
|
||||||
|
|
||||||
|
### No se crean citas
|
||||||
|
|
||||||
|
1. Verificar API key de Odoo.
|
||||||
|
2. Verificar que el paciente exista o se pueda crear.
|
||||||
|
3. Revisar logs de Odoo: `tail -f /tmp/odoo.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Mejoras Futuras
|
||||||
|
|
||||||
|
| Mejora | Descripción | Prioridad |
|
||||||
|
|--------|-------------|-----------|
|
||||||
|
| Integración Hermes Gateway nativa | Usar gateway de Hermes en lugar de bridge custom | Media |
|
||||||
|
| Skills de reportes | Generar reportes via WhatsApp | Baja |
|
||||||
|
| Multi-idioma | Soporte inglés | Baja |
|
||||||
|
| Voz | Integración con voz de WhatsApp | Baja |
|
||||||
|
| Analytics | Tracking de conversaciones | Media |
|
||||||
24
frontend-homenest/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
frontend-homenest/.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
32
frontend-homenest/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## 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 Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
67
frontend-homenest/fix-ts.sh
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /root/skeen-frontend
|
||||||
|
|
||||||
|
# Fix all files: use type-only import and remove unused variables
|
||||||
|
for f in src/App.tsx src/pages/*.tsx; do
|
||||||
|
# Fix import
|
||||||
|
sed -i "s/import { FC } from 'react';/import type { FC } from 'react';/g" "$f"
|
||||||
|
sed -i "s/import { FC, useState } from 'react';/import type { FC } from 'react';\nimport { useState } from 'react';/g" "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Fix Agenda: remove unused selectedDate
|
||||||
|
cat > src/pages/Agenda.tsx << 'AGENDA'
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
const Agenda: FC = () => {
|
||||||
|
// Datos de ejemplo
|
||||||
|
const citas = [
|
||||||
|
{ id: 1, hora: '09:00', paciente: 'Sofia Garcia', servicio: 'Consulta Dermatologica', estado: 'confirmada' },
|
||||||
|
{ id: 2, hora: '10:00', paciente: 'Maria Lopez', servicio: 'Tratamiento Facial Acne', estado: 'confirmada' },
|
||||||
|
{ id: 3, hora: '11:30', paciente: 'Ana Martinez', servicio: 'Peel Quimico', estado: 'pendiente' },
|
||||||
|
{ id: 4, hora: '14:00', paciente: 'Carmen Ruiz', servicio: 'Depilacion Laser', estado: 'confirmada' },
|
||||||
|
{ id: 5, hora: '16:00', paciente: 'Laura Hernandez', servicio: 'Consulta de Valoracion', estado: 'confirmada' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getEstadoColor = (estado: string) => {
|
||||||
|
switch (estado) {
|
||||||
|
case 'confirmada': return 'bg-green-100 text-green-800';
|
||||||
|
case 'pendiente': return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'completada': return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'cancelada': return 'bg-red-100 text-red-800';
|
||||||
|
default: return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-8">Agenda</h1>
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-semibold">Citas del Dia</h2>
|
||||||
|
<button className="bg-skeen-primary text-white px-4 py-2 rounded hover:bg-skeen-secondary transition">+ Nueva Cita</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{citas.map((cita) => (
|
||||||
|
<div key={cita.id} className="p-6 flex items-center justify-between hover:bg-gray-50">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="text-lg font-semibold text-gray-700 w-16">{cita.hora}</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-800">{cita.paciente}</p>
|
||||||
|
<p className="text-sm text-gray-600">{cita.servicio}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getEstadoColor(cita.estado)}`}>{cita.estado}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Agenda;
|
||||||
|
AGENDA
|
||||||
|
|
||||||
|
echo "Fixed TypeScript files"
|
||||||
13
frontend-homenest/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SKEEN Derma Experts — HomeNest</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3166
frontend-homenest/package-lock.json
generated
Normal file
44
frontend-homenest/package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "skeen-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/lato": "^5.2.7",
|
||||||
|
"@fontsource/playfair-display": "^5.2.8",
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.5.2",
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"moment": "^2.30.1",
|
||||||
|
"postcss": "^8.5.16",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-big-calendar": "^1.20.0",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.18.1",
|
||||||
|
"recharts": "^3.9.2",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4.3.2",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"happy-dom": "^20.10.6",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend-homenest/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend-homenest/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 27 KiB |
BIN
frontend-homenest/public/skeen-brand/logos/Logo Mini Blanco.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend-homenest/public/skeen-brand/logos/Logo Mini Negro.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
5
frontend-homenest/src/App.css
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 100svh;
|
||||||
|
}
|
||||||
77
frontend-homenest/src/App.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from './components/ui';
|
||||||
|
import { AuthProvider, useAuth } from './lib/auth';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Agenda from './pages/Agenda';
|
||||||
|
import Pacientes from './pages/Pacientes';
|
||||||
|
import Servicios from './pages/Servicios';
|
||||||
|
import Medicos from './pages/Medicos';
|
||||||
|
import Productos from './pages/Productos';
|
||||||
|
import Monedero from './pages/Monedero';
|
||||||
|
import Pagos from './pages/Pagos';
|
||||||
|
import Ventas from './pages/Ventas';
|
||||||
|
import Cortes from './pages/Cortes';
|
||||||
|
import Reportes from './pages/Reportes';
|
||||||
|
import Configuracion from './pages/Configuracion';
|
||||||
|
import Cumpleanos from './pages/Cumpleanos';
|
||||||
|
import Inventario from './pages/Inventario';
|
||||||
|
import Usuarios from './pages/Usuarios';
|
||||||
|
import WacrmMessages from './pages/WacrmMessages';
|
||||||
|
import WacrmLeads from './pages/WacrmLeads';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
const FullScreenLoader: FC = () => (
|
||||||
|
<div className="min-h-screen bg-[#f8f6f4] flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-[#E9D5B7] border-t-[#3E2C1C] rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href }) => {
|
||||||
|
const { user, loading, canSee } = useAuth();
|
||||||
|
if (loading) return <FullScreenLoader />;
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (!canSee(href)) return <Navigate to="/" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginRoute: FC = () => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <FullScreenLoader />;
|
||||||
|
if (user) return <Navigate to="/" replace />;
|
||||||
|
return <Login />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: FC = () => {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<ToastProvider />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginRoute />} />
|
||||||
|
<Route path="/" element={<RequireAuth href="/"><Dashboard /></RequireAuth>} />
|
||||||
|
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
||||||
|
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
|
||||||
|
<Route path="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
|
||||||
|
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
|
||||||
|
<Route path="/productos" element={<RequireAuth href="/productos"><Productos /></RequireAuth>} />
|
||||||
|
<Route path="/monedero" element={<RequireAuth href="/monedero"><Monedero /></RequireAuth>} />
|
||||||
|
<Route path="/pagos" element={<RequireAuth href="/pagos"><Pagos /></RequireAuth>} />
|
||||||
|
<Route path="/ventas" element={<RequireAuth href="/ventas"><Ventas /></RequireAuth>} />
|
||||||
|
<Route path="/cortes" element={<RequireAuth href="/cortes"><Cortes /></RequireAuth>} />
|
||||||
|
<Route path="/reportes" element={<RequireAuth href="/reportes"><Reportes /></RequireAuth>} />
|
||||||
|
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
|
||||||
|
<Route path="/usuarios" element={<RequireAuth href="/usuarios"><Usuarios /></RequireAuth>} />
|
||||||
|
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
|
||||||
|
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
|
||||||
|
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
|
||||||
|
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
frontend-homenest/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
1
frontend-homenest/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
frontend-homenest/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
376
frontend-homenest/src/components/Layout.tsx
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
Stethoscope,
|
||||||
|
Briefcase,
|
||||||
|
Package,
|
||||||
|
Wallet,
|
||||||
|
CreditCard,
|
||||||
|
ShoppingCart,
|
||||||
|
Receipt,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
Bell,
|
||||||
|
Search,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
ChevronRight,
|
||||||
|
LogOut,
|
||||||
|
MessageSquare,
|
||||||
|
Target,
|
||||||
|
Cake,
|
||||||
|
Boxes,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button, Modal, Input } from './ui';
|
||||||
|
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
||||||
|
import { odooApi } from '../services/odoo';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOGO_BROWN = '/skeen-brand/logos/Logo%20Completo%20Negro.png';
|
||||||
|
|
||||||
|
const menuGroups = [
|
||||||
|
{
|
||||||
|
label: 'Principal',
|
||||||
|
items: [
|
||||||
|
{ label: 'Dashboard', icon: LayoutDashboard, href: '/' },
|
||||||
|
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clínica',
|
||||||
|
items: [
|
||||||
|
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
|
||||||
|
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||||
|
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||||
|
{ label: 'Productos', icon: Package, href: '/productos' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operaciones',
|
||||||
|
items: [
|
||||||
|
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
|
||||||
|
{ label: 'Pagos', icon: CreditCard, href: '/pagos' },
|
||||||
|
{ label: 'Monedero', icon: Wallet, href: '/monedero' },
|
||||||
|
{ label: 'Inventario', icon: Boxes, href: '/inventario' },
|
||||||
|
{ label: 'Cortes de Caja', icon: Receipt, href: '/cortes' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Analítica',
|
||||||
|
items: [
|
||||||
|
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
|
||||||
|
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Comunicación',
|
||||||
|
items: [
|
||||||
|
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Administración',
|
||||||
|
items: [
|
||||||
|
{ label: 'Usuarios', icon: ShieldCheck, href: '/usuarios' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'WACRM',
|
||||||
|
items: [
|
||||||
|
{ label: 'Mensajes', icon: MessageSquare, href: '/wacrm/messages' },
|
||||||
|
{ label: 'Leads', icon: Target, href: '/wacrm/leads' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const Layout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, canSee, hasRole, logout } = useAuth();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const visibleGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
menuGroups
|
||||||
|
.map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.href)) }))
|
||||||
|
.filter((g) => g.items.length > 0),
|
||||||
|
[canSee, user]
|
||||||
|
);
|
||||||
|
|
||||||
|
const initials = (user?.name || 'US')
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]?.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Meta de ventas mensual (header widget)
|
||||||
|
const [goal, setGoal] = useState<{ goal: number; current: number; pct: number } | null>(null);
|
||||||
|
const [goalModalOpen, setGoalModalOpen] = useState(false);
|
||||||
|
const [goalInput, setGoalInput] = useState('');
|
||||||
|
const [goalSaving, setGoalSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const fetchGoal = () => {
|
||||||
|
odooApi.getSalesGoal()
|
||||||
|
.then((res) => {
|
||||||
|
if (alive && res.status === 'success') {
|
||||||
|
setGoal({ goal: res.goal, current: res.current, pct: res.pct });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
fetchGoal();
|
||||||
|
const id = setInterval(fetchGoal, 60000);
|
||||||
|
return () => { alive = false; clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openGoalModal = () => {
|
||||||
|
setGoalInput(goal?.goal ? String(goal.goal) : '');
|
||||||
|
setGoalModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveGoal = async () => {
|
||||||
|
const v = parseFloat(goalInput);
|
||||||
|
if (Number.isNaN(v) || v < 0) return;
|
||||||
|
try {
|
||||||
|
setGoalSaving(true);
|
||||||
|
await odooApi.setSalesGoal(v);
|
||||||
|
setGoal((g) => (g ? { ...g, goal: v, pct: g.current && v ? (g.current / v) * 100 : 0 } : g));
|
||||||
|
setGoalModalOpen(false);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setGoalSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtMoney = (n: number) =>
|
||||||
|
n >= 1000000 ? `$${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `$${(n / 1000).toFixed(0)}k` : `$${n.toFixed(0)}`;
|
||||||
|
const pctClamped = Math.min(goal?.pct ?? 0, 100);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sidebarOpen) return;
|
||||||
|
const originalOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = originalOverflow;
|
||||||
|
};
|
||||||
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
if (href === '/') return location.pathname === '/';
|
||||||
|
return location.pathname === href || location.pathname.startsWith(`${href}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SidebarContent = () => (
|
||||||
|
<>
|
||||||
|
<div className="h-20 flex items-center px-5 lg:px-6 border-b border-homenest-sand shrink-0">
|
||||||
|
<img
|
||||||
|
src={LOGO_BROWN}
|
||||||
|
alt="SKEEN"
|
||||||
|
className="h-8 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto py-5 px-3">
|
||||||
|
{visibleGroups.map((group) => (
|
||||||
|
<div key={group.label} className="mb-6">
|
||||||
|
<p className="px-3 text-[10px] uppercase tracking-wider text-homenest-bark-muted/60 mb-2 font-heading">{group.label}</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.href);
|
||||||
|
return (
|
||||||
|
<li key={item.label}>
|
||||||
|
<Link
|
||||||
|
to={item.href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={`flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
active
|
||||||
|
? 'bg-homenest-brown text-homenest-cream-light shadow-soft'
|
||||||
|
: 'text-homenest-bark-muted hover:bg-homenest-sand-light hover:text-homenest-bark'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{active && <ChevronRight size={14} className="ml-auto" />}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-homenest-sand shrink-0">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-homenest-sage-light flex items-center justify-center text-sm font-bold text-homenest-sage-dark">
|
||||||
|
{initials || 'US'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark truncate">{user?.name || 'Usuario'}</p>
|
||||||
|
<p className="text-xs text-homenest-bark-muted truncate">{user ? ROLE_LABELS[user.role] : ''}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-2 rounded-lg text-homenest-bark-muted hover:text-homenest-bark hover:bg-homenest-sand-light"
|
||||||
|
title="Cerrar sesión"
|
||||||
|
aria-label="Cerrar sesión"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-homenest-cream text-homenest-bark flex">
|
||||||
|
{/* Sidebar desktop */}
|
||||||
|
<aside className="hidden lg:flex flex-col w-64 bg-homenest-cream-light fixed h-full z-20 border-r border-homenest-sand shadow-soft">
|
||||||
|
<SidebarContent />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-homenest-bark/40 z-30 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 w-64 bg-homenest-cream-light z-40 transform transition-transform duration-200 ease-in-out lg:hidden flex flex-col border-r border-homenest-sand shadow-soft ${
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="h-20 flex items-center justify-between px-5 border-b border-homenest-sand shrink-0">
|
||||||
|
<img src={LOGO_BROWN} alt="SKEEN" className="h-8 w-auto object-contain" />
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(false)} aria-label="Cerrar menú">
|
||||||
|
<X size={22} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SidebarContent />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 lg:ml-64 min-h-screen flex flex-col w-full">
|
||||||
|
<header className="bg-homenest-cream-light/80 backdrop-blur border-b border-homenest-sand sticky top-0 z-10">
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
<div className="flex items-center space-x-3 min-w-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="lg:hidden text-homenest-bark"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
aria-label="Abrir menú"
|
||||||
|
>
|
||||||
|
<Menu size={22} />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-lg font-bold text-homenest-bark truncate font-heading tracking-tight">{title}</h2>
|
||||||
|
{subtitle && <p className="text-xs text-homenest-bark-muted hidden sm:block truncate">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 sm:space-x-3 shrink-0">
|
||||||
|
{goal && goal.goal > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={hasRole('admin') ? openGoalModal : undefined}
|
||||||
|
className={`hidden md:flex flex-col w-44 xl:w-56 text-left rounded-xl border border-homenest-sand bg-homenest-cream-light px-3 py-1.5 shadow-card ${hasRole('admin') ? 'hover:border-homenest-brown cursor-pointer' : 'cursor-default'}`}
|
||||||
|
title={hasRole('admin') ? 'Click para editar la meta' : 'Meta de ventas del mes'}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-homenest-bark-muted mb-1">
|
||||||
|
<span className="flex items-center gap-1"><Target size={12} /> Meta del mes</span>
|
||||||
|
<span className="font-bold text-homenest-bark">{Math.round(goal.pct)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-homenest-cream rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${goal.pct >= 100 ? 'bg-homenest-sage' : 'bg-homenest-brown'}`}
|
||||||
|
style={{ width: `${pctClamped}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] mt-1">
|
||||||
|
<span className="font-semibold text-homenest-bark">{fmtMoney(goal.current)}</span>
|
||||||
|
<span className="text-homenest-bark-muted">de {fmtMoney(goal.goal)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{goal && goal.goal <= 0 && hasRole('admin') && (
|
||||||
|
<Button variant="outline" size="sm" className="hidden md:inline-flex" onClick={openGoalModal}>
|
||||||
|
<Target size={14} className="mr-1.5" /> Definir meta
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="hidden md:flex items-center bg-homenest-cream rounded-full px-3 py-1.5 border border-homenest-sand focus-within:border-homenest-brown">
|
||||||
|
<Search size={14} className="text-homenest-bark-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar paciente, cita..."
|
||||||
|
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-48 text-homenest-bark placeholder:text-homenest-bark-muted/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="p-2 rounded-full text-homenest-bark-muted hover:bg-homenest-cream-dark relative">
|
||||||
|
<Bell size={18} />
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 bg-homenest-brown rounded-full" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={goalModalOpen}
|
||||||
|
onClose={() => setGoalModalOpen(false)}
|
||||||
|
title="Meta de ventas del mes"
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-homenest-bark-muted">Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.</p>
|
||||||
|
<Input
|
||||||
|
label="Meta mensual (MXN)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1000}
|
||||||
|
value={goalInput}
|
||||||
|
onChange={(e) => setGoalInput(e.target.value)}
|
||||||
|
placeholder="ej. 500000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
62
frontend-homenest/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export type BadgeVariant =
|
||||||
|
| 'confirmed'
|
||||||
|
| 'pending'
|
||||||
|
| 'done'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'paid'
|
||||||
|
| 'unpaid'
|
||||||
|
| 'info'
|
||||||
|
| 'default'
|
||||||
|
| 'warning'
|
||||||
|
| 'danger'
|
||||||
|
| 'success'
|
||||||
|
| 'primary';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants: Record<BadgeVariant, string> = {
|
||||||
|
confirmed: 'bg-homenest-sage-light text-homenest-bark',
|
||||||
|
pending: 'bg-homenest-cream-dark text-homenest-bark',
|
||||||
|
done: 'bg-homenest-sand-light text-homenest-bark',
|
||||||
|
cancelled: 'bg-rose-100 text-rose-700',
|
||||||
|
paid: 'bg-homenest-sage-light text-homenest-bark',
|
||||||
|
unpaid: 'bg-rose-100 text-rose-700',
|
||||||
|
info: 'bg-homenest-sand-light text-homenest-bark',
|
||||||
|
default: 'bg-homenest-sand-light text-homenest-bark-muted',
|
||||||
|
primary: 'bg-homenest-brown text-white',
|
||||||
|
warning: 'bg-homenest-cream-dark text-homenest-bark',
|
||||||
|
danger: 'bg-rose-100 text-rose-700',
|
||||||
|
success: 'bg-homenest-sage-light text-homenest-bark',
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeVariant = (variant: BadgeVariant): BadgeVariant => {
|
||||||
|
if (variant === 'confirmed' || variant === 'paid' || variant === 'success') return variant;
|
||||||
|
if (variant === 'pending' || variant === 'warning') return variant;
|
||||||
|
if (variant === 'done' || variant === 'info') return variant;
|
||||||
|
if (variant === 'cancelled' || variant === 'danger' || variant === 'unpaid') return variant;
|
||||||
|
return 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Badge: FC<BadgeProps> = ({ children, variant = 'default', className }) => {
|
||||||
|
const normalized = normalizeVariant(variant);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
variants[normalized],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Badge;
|
||||||
59
frontend-homenest/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
loading?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants: Record<NonNullable<ButtonProps['variant']>, string> = {
|
||||||
|
primary:
|
||||||
|
'bg-homenest-brown text-white hover:bg-homenest-brown-dark focus:ring-homenest-brown disabled:bg-homenest-brown/40 shadow-soft',
|
||||||
|
secondary:
|
||||||
|
'bg-homenest-sage text-homenest-bark hover:bg-homenest-sage-dark focus:ring-homenest-sage disabled:bg-homenest-sage/40',
|
||||||
|
ghost:
|
||||||
|
'bg-transparent text-homenest-bark-muted hover:bg-homenest-brown/10 hover:text-homenest-bark focus:ring-homenest-brown',
|
||||||
|
danger:
|
||||||
|
'bg-homenest-rose text-white hover:bg-rose-600 focus:ring-rose-500 disabled:bg-rose-300',
|
||||||
|
outline:
|
||||||
|
'bg-homenest-cream-light text-homenest-bark border border-homenest-sand hover:border-homenest-brown hover:text-homenest-brown focus:ring-homenest-brown',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes: Record<NonNullable<ButtonProps['size']>, string> = {
|
||||||
|
sm: 'px-3 py-1.5 text-xs rounded-lg',
|
||||||
|
md: 'px-4 py-2 text-sm rounded-xl',
|
||||||
|
lg: 'px-6 py-3 text-base rounded-xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button: FC<ButtonProps> = ({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
loading = false,
|
||||||
|
fullWidth = false,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center font-medium transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed rounded-xl',
|
||||||
|
variants[variant],
|
||||||
|
sizes[size],
|
||||||
|
fullWidth && 'w-full',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading && <span className="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
||||||
61
frontend-homenest/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
noPadding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardHeaderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardBodyProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
noPadding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Card: FC<CardProps> & {
|
||||||
|
Header: FC<CardHeaderProps>;
|
||||||
|
Body: FC<CardBodyProps>;
|
||||||
|
} = ({ children, className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-homenest-cream-light rounded-2xl border border-homenest-sand shadow-card',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardHeader: FC<CardHeaderProps> = ({ children, className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-4 sm:px-6 sm:py-5 border-b border-homenest-sand flex flex-col sm:flex-row sm:items-center justify-between gap-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardBody: FC<CardBodyProps> = ({ children, className, noPadding = false }) => {
|
||||||
|
return (
|
||||||
|
<div className={cn(!noPadding && 'p-4 sm:p-6', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Card.Header = CardHeader;
|
||||||
|
Card.Body = CardBody;
|
||||||
|
|
||||||
|
export default Card;
|
||||||
39
frontend-homenest/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { Inbox } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyState: FC<EmptyStateProps> = ({
|
||||||
|
title = 'Sin resultados',
|
||||||
|
subtitle = 'No hay datos para mostrar en este momento.',
|
||||||
|
icon,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||||
|
<div className="w-14 h-14 rounded-full bg-[#FEF3C7] flex items-center justify-center text-[#A87B5D] mb-4">
|
||||||
|
{icon ?? <Inbox size={28} />}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-heading text-lg text-homenest-bark mb-1">{title}</h4>
|
||||||
|
<p className="text-sm text-[#7A5C44] max-w-xs mx-auto mb-4">{subtitle}</p>
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmptyState;
|
||||||
34
frontend-homenest/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FC, InputHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input: FC<InputProps> = ({ label, error, className, inputClassName, labelClassName, id, ...props }) => {
|
||||||
|
const inputId = id ?? (label ? `input-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className={cn('block text-xs font-medium mb-1.5', labelClassName || 'text-homenest-bark-muted')}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-xl border bg-homenest-cream-light px-3 py-2.5 text-sm text-homenest-bark placeholder:text-homenest-bark-muted/60 focus:outline-none focus:ring-2 focus:ring-homenest-brown/30 focus:border-homenest-brown transition',
|
||||||
|
error ? 'border-homenest-rose focus:border-homenest-rose focus:ring-homenest-rose/20' : 'border-homenest-sand',
|
||||||
|
inputClassName
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="mt-1.5 text-xs text-homenest-rose">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Input;
|
||||||
50
frontend-homenest/src/components/ui/MobileCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface MobileCardProps {
|
||||||
|
title: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
rows: { label: string; value: ReactNode }[];
|
||||||
|
actions?: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileCard: FC<MobileCardProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
rows,
|
||||||
|
actions,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-homenest-cream-light rounded-2xl border border-[#F5EBD8] shadow-sm p-4 sm:hidden',
|
||||||
|
onClick && 'cursor-pointer active:bg-[#FEF3C7]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
role={onClick ? 'button' : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-homenest-bark truncate">{title}</div>
|
||||||
|
{subtitle && <div className="text-xs text-[#7A5C44] mt-0.5">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-1 shrink-0">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-y-2 gap-x-3">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className={cn(i === 0 && rows.length % 2 === 1 ? 'col-span-2' : '')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-[#A87B5D]">{row.label}</p>
|
||||||
|
<div className="text-sm text-homenest-bark truncate">{row.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileCard;
|
||||||
86
frontend-homenest/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidthClasses: Record<NonNullable<ModalProps['maxWidth']>, string> = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
'2xl': 'max-w-2xl',
|
||||||
|
full: 'max-w-[calc(100vw-2rem)]',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Modal: FC<ModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
maxWidth = 'md',
|
||||||
|
footer,
|
||||||
|
}) => {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const originalOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = originalOverflow;
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-homenest-bark/40"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className={cn(
|
||||||
|
'relative bg-homenest-cream-light rounded-2xl shadow-xl w-full flex flex-col max-h-[calc(100vh-2rem)]',
|
||||||
|
maxWidthClasses[maxWidth]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-homenest-sand">
|
||||||
|
<h3 className="font-heading text-lg sm:text-xl text-homenest-bark pr-4">{title}</h3>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Cerrar">
|
||||||
|
<X size={18} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 sm:p-6">{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<div className="px-5 py-4 border-t border-homenest-sand flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
28
frontend-homenest/src/components/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeader: FC<PageHeaderProps> = ({ title, subtitle, children, className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-heading text-2xl sm:text-3xl text-homenest-bark">{title}</h2>
|
||||||
|
{subtitle && <p className="text-sm text-homenest-bark-muted mt-1">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{children && <div className="flex items-center gap-2 shrink-0">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
38
frontend-homenest/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FC, SelectHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select: FC<SelectProps> = ({ label, error, className, options, id, ...props }) => {
|
||||||
|
const selectId = id ?? (label ? `select-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={selectId} className="block text-xs font-medium text-homenest-bark-muted mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
id={selectId}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-xl border bg-homenest-cream-light px-3 py-2.5 text-sm text-homenest-bark focus:outline-none focus:ring-2 focus:ring-homenest-brown/30 focus:border-homenest-brown transition appearance-none',
|
||||||
|
error ? 'border-homenest-rose focus:border-homenest-rose focus:ring-homenest-rose/20' : 'border-homenest-sand'
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="mt-1.5 text-xs text-homenest-rose">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Select;
|
||||||
52
frontend-homenest/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Skeleton: FC<SkeletonProps> = ({ className, count = 1 }) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 animate-pulse">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn('rounded-xl bg-[#F5EBD8]', className)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SkeletonCard: FC<{ lines?: number; className?: string }> = ({
|
||||||
|
lines = 3,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={cn('bg-homenest-cream-light rounded-2xl p-4 sm:p-6 border border-[#F5EBD8] shadow-sm', className)}>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Skeleton className="h-5 w-1/3" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SkeletonStat: FC = () => (
|
||||||
|
<div className="bg-homenest-cream-light rounded-2xl p-5 border border-[#F5EBD8] shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Skeleton className="h-9 w-9 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-20 mb-2" />
|
||||||
|
<Skeleton className="h-7 w-24" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Skeleton;
|
||||||
31
frontend-homenest/src/components/ui/TextArea.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { FC, TextareaHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextArea: FC<TextAreaProps> = ({ label, error, className, id, ...props }) => {
|
||||||
|
const areaId = id ?? (label ? `textarea-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={areaId} className="block text-xs font-medium text-[#7A5C44] mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
id={areaId}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-xl border bg-homenest-cream-light px-3 py-2.5 text-sm text-homenest-bark placeholder:text-[#A87B5D] focus:outline-none focus:ring-2 focus:ring-[#8B5E3C]/30 focus:border-[#3E2C1C] transition resize-y min-h-[80px]',
|
||||||
|
error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-[#E9D5B7]'
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="mt-1.5 text-xs text-rose-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextArea;
|
||||||
19
frontend-homenest/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Toaster } from 'sonner';
|
||||||
|
|
||||||
|
export const ToastProvider = () => {
|
||||||
|
return (
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
fontFamily:
|
||||||
|
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToastProvider;
|
||||||
19
frontend-homenest/src/components/ui/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export { Button } from './Button';
|
||||||
|
export { Card } from './Card';
|
||||||
|
export { Input } from './Input';
|
||||||
|
export { Select } from './Select';
|
||||||
|
export { TextArea } from './TextArea';
|
||||||
|
export { Modal } from './Modal';
|
||||||
|
export { Badge } from './Badge';
|
||||||
|
export { EmptyState } from './EmptyState';
|
||||||
|
export { Skeleton, SkeletonCard, SkeletonStat } from './Skeleton';
|
||||||
|
export { MobileCard } from './MobileCard';
|
||||||
|
export { PageHeader } from './PageHeader';
|
||||||
|
export { ToastProvider } from './Toast';
|
||||||
|
export { toast } from '../../lib/toast';
|
||||||
|
export {
|
||||||
|
badgeForAppointmentState,
|
||||||
|
badgeForPaymentState,
|
||||||
|
badgeForSaleState,
|
||||||
|
badgeForCashClosingState,
|
||||||
|
} from '../../lib/badges';
|
||||||
103
frontend-homenest/src/index.css
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Alike&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Paleta HomeNest */
|
||||||
|
--hn-cream: #FEF3C7;
|
||||||
|
--hn-cream-light: #FFF9E6;
|
||||||
|
--hn-cream-dark: #F3E2B3;
|
||||||
|
--hn-brown: #8B5E3C;
|
||||||
|
--hn-brown-dark: #6F4B30;
|
||||||
|
--hn-brown-light: #A87B5D;
|
||||||
|
--hn-sage: #84CC16;
|
||||||
|
--hn-sage-light: #D9F99D;
|
||||||
|
--hn-sage-dark: #65A30D;
|
||||||
|
--hn-bark: #3E2C1C;
|
||||||
|
--hn-bark-muted: #7A5C44;
|
||||||
|
--hn-bark-light: #5C4634;
|
||||||
|
--hn-sand: #E9D5B7;
|
||||||
|
--hn-sand-light: #F5EBD8;
|
||||||
|
--hn-white: #FFFFFF;
|
||||||
|
--hn-rose: #EF4444;
|
||||||
|
|
||||||
|
/* Uso semántico */
|
||||||
|
--text: var(--hn-bark);
|
||||||
|
--text-muted: var(--hn-bark-muted);
|
||||||
|
--text-heading: var(--hn-bark);
|
||||||
|
--bg: var(--hn-cream);
|
||||||
|
--surface: var(--hn-cream-light);
|
||||||
|
--border: var(--hn-sand);
|
||||||
|
--accent: var(--hn-brown);
|
||||||
|
--accent-hover: var(--hn-brown-dark);
|
||||||
|
--accent-bg: var(--hn-sand-light);
|
||||||
|
|
||||||
|
--font-sans: 'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--font-display: 'Alike', Georgia, serif;
|
||||||
|
--font-heading: 'Alike', Georgia, serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-heading);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar cálida */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(139, 94, 60, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(139, 94, 60, 0.4);
|
||||||
|
}
|
||||||
135
frontend-homenest/src/lib/auth.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'skeen_token';
|
||||||
|
const USER_KEY = 'skeen_user';
|
||||||
|
|
||||||
|
const ROLE_RANK: Record<FrontendRole, number> = {
|
||||||
|
lectura: 10,
|
||||||
|
medico: 50,
|
||||||
|
recepcion: 50,
|
||||||
|
admin: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Visibilidad del menú por ruta. Si una ruta no está listada, se muestra a todos los roles.
|
||||||
|
const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||||
|
'/': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||||
|
'/agenda': ['admin', 'recepcion', 'medico'],
|
||||||
|
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||||
|
'/medicos': ['admin', 'recepcion'],
|
||||||
|
'/servicios': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||||
|
'/productos': ['admin', 'recepcion', 'lectura'],
|
||||||
|
'/ventas': ['admin', 'recepcion'],
|
||||||
|
'/pagos': ['admin', 'recepcion'],
|
||||||
|
'/monedero': ['admin', 'recepcion', 'lectura'],
|
||||||
|
'/inventario': ['admin', 'recepcion', 'lectura'],
|
||||||
|
'/cortes': ['admin', 'recepcion'],
|
||||||
|
'/reportes': ['admin', 'recepcion', 'lectura'],
|
||||||
|
'/configuracion': ['admin'],
|
||||||
|
'/usuarios': ['admin'],
|
||||||
|
'/cumpleanos': ['admin', 'recepcion'],
|
||||||
|
'/wacrm/messages': ['admin', 'recepcion'],
|
||||||
|
'/wacrm/leads': ['admin', 'recepcion'],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: FrontendUser | null;
|
||||||
|
token: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (login: string, password: string) => Promise<FrontendUser>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
hasRole: (min: FrontendRole) => boolean;
|
||||||
|
canSee: (href: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<FrontendUser | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Validar token al arrancar
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const t = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (!t) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hidratar usuario cacheado para evitar parpadeo
|
||||||
|
const cached = localStorage.getItem(USER_KEY);
|
||||||
|
if (cached) {
|
||||||
|
try { setUser(JSON.parse(cached)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
odooApi.me()
|
||||||
|
.then((res) => {
|
||||||
|
if (!alive) return;
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setUser(res.user);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
|
||||||
|
} else {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { if (alive) clear(); })
|
||||||
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [clear]);
|
||||||
|
|
||||||
|
const login = async (login: string, password: string): Promise<FrontendUser> => {
|
||||||
|
const res = await odooApi.login(login, password);
|
||||||
|
if (res.status !== 'success' || !res.token) {
|
||||||
|
throw new Error(res.message || 'Credenciales inválidas');
|
||||||
|
}
|
||||||
|
localStorage.setItem(TOKEN_KEY, res.token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
|
||||||
|
setToken(res.token);
|
||||||
|
setUser(res.user);
|
||||||
|
return res.user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try { await odooApi.logout(); } catch { /* ignore */ }
|
||||||
|
clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRole = (min: FrontendRole): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
return (ROLE_RANK[user.role] ?? 0) >= (ROLE_RANK[min] ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSee = (href: string): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
const allowed = MENU_ROLES[href];
|
||||||
|
if (!allowed) return true;
|
||||||
|
return allowed.includes(user.role);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, token, loading, login, logout, hasRole, canSee }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextValue => {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_LABELS: Record<FrontendRole, string> = {
|
||||||
|
admin: 'Administrador',
|
||||||
|
recepcion: 'Recepción',
|
||||||
|
medico: 'Médico',
|
||||||
|
lectura: 'Solo lectura',
|
||||||
|
};
|
||||||
50
frontend-homenest/src/lib/badges.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { BadgeVariant } from '../components/ui/Badge';
|
||||||
|
|
||||||
|
export const badgeForAppointmentState = (state: string): BadgeVariant => {
|
||||||
|
switch (state) {
|
||||||
|
case 'confirmed':
|
||||||
|
return 'confirmed';
|
||||||
|
case 'done':
|
||||||
|
case 'completed':
|
||||||
|
return 'done';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'cancelled';
|
||||||
|
default:
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const badgeForPaymentState = (state: string): BadgeVariant => {
|
||||||
|
switch (state) {
|
||||||
|
case 'confirmed':
|
||||||
|
case 'posted':
|
||||||
|
case 'paid':
|
||||||
|
return 'paid';
|
||||||
|
default:
|
||||||
|
return 'unpaid';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const badgeForSaleState = (state: string): BadgeVariant => {
|
||||||
|
switch (state) {
|
||||||
|
case 'sale':
|
||||||
|
case 'done':
|
||||||
|
case 'paid':
|
||||||
|
return 'paid';
|
||||||
|
case 'cancel':
|
||||||
|
return 'cancelled';
|
||||||
|
default:
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const badgeForCashClosingState = (state: string): BadgeVariant => {
|
||||||
|
switch (state) {
|
||||||
|
case 'closed':
|
||||||
|
return 'done';
|
||||||
|
case 'open':
|
||||||
|
return 'pending';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
1
frontend-homenest/src/lib/toast.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { toast } from 'sonner';
|
||||||
27
frontend-homenest/src/lib/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
type CsvCell = string | number | boolean | null | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descarga un CSV (UTF-8 con BOM para que Excel respete acentos).
|
||||||
|
* Cada fila es un arreglo de celdas; se escapan comillas automáticamente.
|
||||||
|
*/
|
||||||
|
export function downloadCsv(filename: string, rows: CsvCell[][]) {
|
||||||
|
const csv = rows
|
||||||
|
.map((row) => row.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\r\n');
|
||||||
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
10
frontend-homenest/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
612
frontend-homenest/src/pages/Agenda.tsx
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, Plus, PackageCheck, Sparkles, RotateCcw } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
TextArea,
|
||||||
|
Modal,
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
MobileCard,
|
||||||
|
PageHeader,
|
||||||
|
Skeleton,
|
||||||
|
toast,
|
||||||
|
badgeForAppointmentState,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type Appointment, type Patient, type Service, type Doctor } from '../services/odoo';
|
||||||
|
|
||||||
|
const stateOptions = [
|
||||||
|
{ value: '', label: 'Todos los estados' },
|
||||||
|
{ value: 'pending', label: 'Pendiente' },
|
||||||
|
{ value: 'confirmed', label: 'Confirmada' },
|
||||||
|
{ value: 'done', label: 'Completada' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelada' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Agenda: FC = () => {
|
||||||
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [updating, setUpdating] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [date, setDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
|
||||||
|
const [stateFilter, setStateFilter] = useState('');
|
||||||
|
const [doctorFilter, setDoctorFilter] = useState('');
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
|
||||||
|
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
patient_id: '',
|
||||||
|
service_id: '',
|
||||||
|
doctor_id: '',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
time: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
const [patientSearch, setPatientSearch] = useState('');
|
||||||
|
const [pkgUpdating, setPkgUpdating] = useState<number | null>(null);
|
||||||
|
const [expressOpen, setExpressOpen] = useState(false);
|
||||||
|
const [expressSubmitting, setExpressSubmitting] = useState(false);
|
||||||
|
const [expressForm, setExpressForm] = useState({
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
notes: '',
|
||||||
|
withAppointment: false,
|
||||||
|
service_id: '',
|
||||||
|
doctor_id: '',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
time: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredAppointments = appointments.filter((apt) => {
|
||||||
|
if (stateFilter && apt.state !== stateFilter) return false;
|
||||||
|
if (doctorFilter && String(apt.doctor_id) !== doctorFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const params: Record<string, string> = date ? { date } : {};
|
||||||
|
const res = await odooApi.getAppointments(params);
|
||||||
|
if (res.status === 'success') setAppointments(res.appointments);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar citas');
|
||||||
|
toast.error('Error al cargar citas');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
|
const loadReferences = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [servicesRes, doctorsRes] = await Promise.all([
|
||||||
|
odooApi.getServices(),
|
||||||
|
odooApi.getDoctors(),
|
||||||
|
]);
|
||||||
|
if (servicesRes.status === 'success') setServices(servicesRes.services);
|
||||||
|
if (doctorsRes.status === 'success') setDoctors(doctorsRes.doctors);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
loadReferences();
|
||||||
|
}, [load, loadReferences]);
|
||||||
|
|
||||||
|
// Cargar pacientes para el selector (búsqueda server-side)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createOpen) return;
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getPatients({ search: patientSearch, page_size: 50 });
|
||||||
|
if (res.status === 'success') setPatients(res.patients);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [createOpen, patientSearch]);
|
||||||
|
|
||||||
|
const fetchSlots = useCallback(async (serviceId: string, aptDate: string) => {
|
||||||
|
if (!serviceId || !aptDate) {
|
||||||
|
setSlots([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSlotsLoading(true);
|
||||||
|
const res = await odooApi.getAvailableSlots(aptDate, parseInt(serviceId, 10));
|
||||||
|
if (res.status === 'success') setSlots(res.slots);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar horarios disponibles');
|
||||||
|
setSlots([]);
|
||||||
|
} finally {
|
||||||
|
setSlotsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSlots(form.service_id, form.date);
|
||||||
|
}, [form.service_id, form.date, fetchSlots]);
|
||||||
|
|
||||||
|
const handleStatus = async (id: number, action: string) => {
|
||||||
|
try {
|
||||||
|
setUpdating(id);
|
||||||
|
await odooApi.updateAppointmentStatus(id, action);
|
||||||
|
toast.success('Estado actualizado');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al actualizar estado');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setUpdating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePackageFinished = async (id: number, finished: boolean) => {
|
||||||
|
try {
|
||||||
|
setPkgUpdating(id);
|
||||||
|
await odooApi.packageFinished(id, finished);
|
||||||
|
toast.success(finished ? 'Paquete marcado como terminado' : 'Marca de paquete revertida');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al actualizar paquete');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setPkgUpdating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTimeToFloat = (t: string): number | undefined => {
|
||||||
|
if (!t) return undefined;
|
||||||
|
const [h, m] = t.split(':').map((x) => parseInt(x, 10));
|
||||||
|
if (Number.isNaN(h)) return undefined;
|
||||||
|
return h + (Number.isNaN(m) ? 0 : m / 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetExpressForm = () =>
|
||||||
|
setExpressForm({
|
||||||
|
name: '', phone: '', notes: '', withAppointment: false,
|
||||||
|
service_id: '', doctor_id: '', date: new Date().toISOString().split('T')[0], time: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitExpress = async () => {
|
||||||
|
if (!expressForm.name.trim() || !expressForm.phone.trim()) {
|
||||||
|
toast.error('Nombre y teléfono son obligatorios');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (expressForm.withAppointment && (!expressForm.service_id || !expressForm.date || !expressForm.time)) {
|
||||||
|
toast.error('Para la cita de valoración selecciona servicio, fecha y hora');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setExpressSubmitting(true);
|
||||||
|
const patientPayload: Partial<Patient> & { notes?: string } = {
|
||||||
|
name: expressForm.name.trim(),
|
||||||
|
phone: expressForm.phone.trim(),
|
||||||
|
source: 'walkin',
|
||||||
|
notes: expressForm.notes || undefined,
|
||||||
|
};
|
||||||
|
const res = await odooApi.createPatient(patientPayload);
|
||||||
|
const newId = res.patient?.id;
|
||||||
|
if (expressForm.withAppointment && newId) {
|
||||||
|
await odooApi.createAppointment({
|
||||||
|
patient_id: newId,
|
||||||
|
service_id: parseInt(expressForm.service_id, 10),
|
||||||
|
doctor_id: expressForm.doctor_id ? parseInt(expressForm.doctor_id, 10) : undefined,
|
||||||
|
date: expressForm.date,
|
||||||
|
time: parseTimeToFloat(expressForm.time) as unknown as string,
|
||||||
|
notes: 'Valoración express',
|
||||||
|
state: 'confirmed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success(expressForm.withAppointment ? 'Prospecto y cita de valoración creados' : 'Prospecto creado');
|
||||||
|
setExpressOpen(false);
|
||||||
|
resetExpressForm();
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error en valoración express');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setExpressSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAppointment = async () => {
|
||||||
|
if (!form.patient_id || !form.service_id || !form.date || !form.time) {
|
||||||
|
toast.error('Completa los campos obligatorios');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await odooApi.createAppointment({
|
||||||
|
patient_id: parseInt(form.patient_id, 10),
|
||||||
|
service_id: parseInt(form.service_id, 10),
|
||||||
|
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : undefined,
|
||||||
|
date: form.date,
|
||||||
|
time: form.time,
|
||||||
|
notes: form.notes,
|
||||||
|
});
|
||||||
|
toast.success('Cita creada');
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({
|
||||||
|
patient_id: '',
|
||||||
|
service_id: '',
|
||||||
|
doctor_id: '',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
time: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al crear cita');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doctorOptions = [
|
||||||
|
{ value: '', label: 'Todos los médicos' },
|
||||||
|
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const patientOptions = [
|
||||||
|
{ value: '', label: 'Seleccionar paciente...' },
|
||||||
|
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const serviceOptions = [
|
||||||
|
{ value: '', label: 'Seleccionar servicio...' },
|
||||||
|
...services.map((s) => ({ value: String(s.id), label: `${s.name} — $${s.price}` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const slotOptions = slots.length
|
||||||
|
? [
|
||||||
|
{ value: '', label: 'Seleccionar hora...' },
|
||||||
|
...slots.map((s) => ({ value: s.time_str, label: s.time_str })),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: '', label: 'Selecciona servicio y fecha primero' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TableHeader = () => (
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Hora</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Paciente</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden sm:table-cell">Servicio</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden md:table-cell">Doctor</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Estado</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden sm:table-cell">Pago</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ActionButtons = ({ apt }: { apt: Appointment }) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStatus(apt.id, 'confirm')}
|
||||||
|
disabled={updating === apt.id}
|
||||||
|
title="Confirmar"
|
||||||
|
>
|
||||||
|
<CheckCircle size={16} className="text-#3E2C1C" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{apt.state === 'confirmed' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStatus(apt.id, 'arrive')}
|
||||||
|
disabled={updating === apt.id}
|
||||||
|
title="Llegada"
|
||||||
|
>
|
||||||
|
<UserCheck size={16} className="text-blue-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStatus(apt.id, 'done')}
|
||||||
|
disabled={updating === apt.id}
|
||||||
|
title="Completar"
|
||||||
|
>
|
||||||
|
<CheckCheck size={16} className="text-blue-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
|
||||||
|
apt.package_finished ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePackageFinished(apt.id, false)}
|
||||||
|
disabled={pkgUpdating === apt.id}
|
||||||
|
title="Paquete terminado — click para revertir"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} className="text-amber-600" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePackageFinished(apt.id, true)}
|
||||||
|
disabled={pkgUpdating === apt.id}
|
||||||
|
title="Marcar paquete terminado"
|
||||||
|
>
|
||||||
|
<PackageCheck size={16} className="text-violet-600" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{apt.state !== 'cancelled' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStatus(apt.id, 'cancel')}
|
||||||
|
disabled={updating === apt.id}
|
||||||
|
title="Cancelar"
|
||||||
|
>
|
||||||
|
<XCircle size={16} className="text-rose-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Agenda" subtitle="Gestión de citas">
|
||||||
|
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
|
||||||
|
<Button variant="outline" onClick={() => setExpressOpen(true)}>
|
||||||
|
<Sparkles size={16} className="mr-2" />
|
||||||
|
Valoración express
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Nueva cita
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||||
|
<Input
|
||||||
|
label="Fecha"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="sm:max-w-[180px]"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Estado"
|
||||||
|
options={stateOptions}
|
||||||
|
value={stateFilter}
|
||||||
|
onChange={(e) => setStateFilter(e.target.value)}
|
||||||
|
className="sm:max-w-[180px]"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Médico"
|
||||||
|
options={doctorOptions}
|
||||||
|
value={doctorFilter}
|
||||||
|
onChange={(e) => setDoctorFilter(e.target.value)}
|
||||||
|
className="sm:max-w-[220px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={6} className="h-12 w-full" />
|
||||||
|
) : filteredAppointments.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Sin citas"
|
||||||
|
subtitle="No hay citas para los filtros seleccionados."
|
||||||
|
actionLabel="Nueva cita"
|
||||||
|
onAction={() => setCreateOpen(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<TableHeader />
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{filteredAppointments.map((apt) => (
|
||||||
|
<tr key={apt.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3 text-sm text-homenest-bark">
|
||||||
|
<Clock size={14} className="inline mr-1 text-[#A87B5D]" />
|
||||||
|
{apt.time}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">{apt.patient}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden sm:table-cell">{apt.service}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden md:table-cell">{apt.doctor || '-'}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden sm:table-cell">{apt.payment_state}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<ActionButtons apt={apt} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile cards */}
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{filteredAppointments.map((apt) => (
|
||||||
|
<MobileCard
|
||||||
|
key={apt.id}
|
||||||
|
title={apt.patient}
|
||||||
|
subtitle={apt.service}
|
||||||
|
rows={[
|
||||||
|
{ label: 'Hora', value: apt.time },
|
||||||
|
{ label: 'Doctor', value: apt.doctor || '-' },
|
||||||
|
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge> },
|
||||||
|
{ label: 'Pago', value: apt.payment_state },
|
||||||
|
]}
|
||||||
|
actions={<ActionButtons apt={apt} />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
title="Nueva cita"
|
||||||
|
maxWidth="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={createAppointment} loading={submitting}>Guardar cita</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Buscar paciente"
|
||||||
|
placeholder="Escribe nombre o teléfono..."
|
||||||
|
value={patientSearch}
|
||||||
|
onChange={(e) => setPatientSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Paciente *"
|
||||||
|
options={patientOptions}
|
||||||
|
value={form.patient_id}
|
||||||
|
onChange={(e) => setForm({ ...form, patient_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Servicio *"
|
||||||
|
options={serviceOptions}
|
||||||
|
value={form.service_id}
|
||||||
|
onChange={(e) => setForm({ ...form, service_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Médico"
|
||||||
|
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||||
|
value={form.doctor_id}
|
||||||
|
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Fecha *"
|
||||||
|
type="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Hora *"
|
||||||
|
options={slotOptions}
|
||||||
|
value={form.time}
|
||||||
|
onChange={(e) => setForm({ ...form, time: e.target.value })}
|
||||||
|
disabled={slotsLoading || !form.service_id || !form.date}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{slotsLoading && <p className="text-xs text-[#7A5C44]">Cargando horarios disponibles...</p>}
|
||||||
|
<TextArea
|
||||||
|
label="Notas"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={expressOpen}
|
||||||
|
onClose={() => setExpressOpen(false)}
|
||||||
|
title="Valoración express"
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setExpressOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={submitExpress} loading={expressSubmitting}>Guardar</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
Captura rápida de prospecto. Opcionalmente agenda su cita de valoración en el mismo paso.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Nombre *"
|
||||||
|
placeholder="Nombre del prospecto"
|
||||||
|
value={expressForm.name}
|
||||||
|
onChange={(e) => setExpressForm({ ...expressForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Teléfono *"
|
||||||
|
placeholder="+52 ..."
|
||||||
|
value={expressForm.phone}
|
||||||
|
onChange={(e) => setExpressForm({ ...expressForm, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
label="Notas"
|
||||||
|
placeholder="Motivo de consulta, zona a tratar, etc."
|
||||||
|
value={expressForm.notes}
|
||||||
|
onChange={(e) => setExpressForm({ ...expressForm, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-homenest-bark cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={expressForm.withAppointment}
|
||||||
|
onChange={(e) => setExpressForm({ ...expressForm, withAppointment: e.target.checked })}
|
||||||
|
className="rounded border-[#E9D5B7]"
|
||||||
|
/>
|
||||||
|
Crear cita de valoración ahora
|
||||||
|
</label>
|
||||||
|
{expressForm.withAppointment && (
|
||||||
|
<div className="space-y-4 rounded-xl border border-[#F5EBD8] p-4 bg-[#FEF3C7]">
|
||||||
|
<Select
|
||||||
|
label="Servicio *"
|
||||||
|
options={serviceOptions}
|
||||||
|
value={expressForm.service_id}
|
||||||
|
onChange={(e) => setExpressForm({ ...expressForm, service_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Médico"
|
||||||
|
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||||
|
value={expressForm.doctor_id}
|
||||||
|
onChange={(e) => setExpressForm({ ...expressForm, doctor_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Fecha *"
|
||||||
|
type="date"
|
||||||
|
value={expressForm.date}
|
||||||
|
onChange={(e) => setExpressForm({ ...expressForm, date: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Hora *"
|
||||||
|
type="time"
|
||||||
|
value={expressForm.time}
|
||||||
|
onChange={(e) => setExpressForm({ ...expressForm, time: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Agenda;
|
||||||
241
frontend-homenest/src/pages/Configuracion.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||||
|
import { odooApi } from '../services/odoo';
|
||||||
|
|
||||||
|
interface ClinicSettings {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
address: string;
|
||||||
|
website: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSettings: ClinicSettings = {
|
||||||
|
name: 'SKEEN Derma Experts',
|
||||||
|
phone: '+52 (661) 100-2172',
|
||||||
|
email: 'hola@skeen.mx',
|
||||||
|
address: 'Playas de Rosarito, Baja California',
|
||||||
|
website: 'https://skeen.mx',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'skeen_clinic_settings';
|
||||||
|
|
||||||
|
const Configuracion: FC = () => {
|
||||||
|
const [settings, setSettings] = useState<ClinicSettings>(defaultSettings);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) setSettings(JSON.parse(saved));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
toast.success('Configuración guardada');
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al guardar configuración');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (field: keyof ClinicSettings, value: string) => {
|
||||||
|
setSettings((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Tipo de cambio USD/MXN ----
|
||||||
|
const [tcCurrent, setTcCurrent] = useState<{ rate: number; date: string; source: string } | null>(null);
|
||||||
|
const [tcInput, setTcInput] = useState('');
|
||||||
|
const [tcSaving, setTcSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
odooApi.getExchangeRate()
|
||||||
|
.then((res) => {
|
||||||
|
if (res.current) {
|
||||||
|
setTcCurrent(res.current);
|
||||||
|
setTcInput(String(res.current.rate));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {/* odoo no disponible, ignorar */});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveTc = async () => {
|
||||||
|
const rate = parseFloat(tcInput);
|
||||||
|
if (!rate || rate <= 0) {
|
||||||
|
toast.error('Ingresa un tipo de cambio válido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTcSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.setExchangeRate(rate, 'manual');
|
||||||
|
setTcCurrent(res.exchange_rate);
|
||||||
|
toast.success(`Tipo de cambio actualizado: ${rate.toFixed(2)} MXN/USD`);
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo guardar el tipo de cambio');
|
||||||
|
} finally {
|
||||||
|
setTcSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Configuración" subtitle="Ajustes de la clínica">
|
||||||
|
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h3 className="font-heading text-xl text-homenest-bark mb-4">Información de la clínica</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.address)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="p-4 bg-[#FEF3C7] rounded-xl flex items-start space-x-3 hover:bg-[#FEF3C7] transition"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-homenest-cream-light flex items-center justify-center text-[#7A5C44] shadow-sm shrink-0">
|
||||||
|
<MapPin size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark">Dirección</p>
|
||||||
|
<p className="text-sm text-[#7A5C44] truncate">{settings.address}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`tel:${settings.phone}`}
|
||||||
|
className="p-4 bg-[#FEF3C7] rounded-xl flex items-start space-x-3 hover:bg-[#FEF3C7] transition"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-homenest-cream-light flex items-center justify-center text-[#7A5C44] shadow-sm shrink-0">
|
||||||
|
<Phone size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark">Teléfono</p>
|
||||||
|
<p className="text-sm text-[#7A5C44] truncate">{settings.phone}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`mailto:${settings.email}`}
|
||||||
|
className="p-4 bg-[#FEF3C7] rounded-xl flex items-start space-x-3 hover:bg-[#FEF3C7] transition"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-homenest-cream-light flex items-center justify-center text-[#7A5C44] shadow-sm shrink-0">
|
||||||
|
<Mail size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark">Email</p>
|
||||||
|
<p className="text-sm text-[#7A5C44] truncate">{settings.email}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={settings.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="p-4 bg-[#FEF3C7] rounded-xl flex items-start space-x-3 hover:bg-[#FEF3C7] transition"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-homenest-cream-light flex items-center justify-center text-[#7A5C44] shadow-sm shrink-0">
|
||||||
|
<Globe size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark">Sitio web</p>
|
||||||
|
<p className="text-sm text-[#7A5C44] truncate">{settings.website.replace(/^https?:\/\//, '')}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h3 className="font-heading text-xl text-homenest-bark mb-4 flex items-center">
|
||||||
|
<Building2 size={20} className="mr-2" />
|
||||||
|
Ajustes
|
||||||
|
</h3>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-[#7A5C44]">Cargando...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Nombre de la clínica"
|
||||||
|
value={settings.name}
|
||||||
|
onChange={(e) => update('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Teléfono"
|
||||||
|
value={settings.phone}
|
||||||
|
onChange={(e) => update('phone', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={settings.email}
|
||||||
|
onChange={(e) => update('email', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Dirección"
|
||||||
|
value={settings.address}
|
||||||
|
onChange={(e) => update('address', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Sitio web"
|
||||||
|
value={settings.website}
|
||||||
|
onChange={(e) => update('website', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button onClick={save}>
|
||||||
|
<Save size={16} className="mr-2" />
|
||||||
|
Guardar ajustes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<Card.Body>
|
||||||
|
<h3 className="font-heading text-xl text-homenest-bark mb-1 flex items-center">
|
||||||
|
<ArrowRightLeft size={20} className="mr-2" />
|
||||||
|
Tipo de cambio USD / MXN
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[#7A5C44] mb-4">
|
||||||
|
Se usa para convertir ventas en dólares. Vigente:
|
||||||
|
{tcCurrent ? (
|
||||||
|
<span className="font-semibold text-homenest-bark"> {Number(tcCurrent.rate).toFixed(2)} MXN por 1 USD <span className="font-normal text-[#A87B5D]">({tcCurrent.date})</span></span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[#A87B5D]"> sin definir</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-3 max-w-md">
|
||||||
|
<div className="flex-1 w-full">
|
||||||
|
<Input
|
||||||
|
label="Nuevo tipo de cambio (MXN por 1 USD)"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={tcInput}
|
||||||
|
onChange={(e) => setTcInput(e.target.value)}
|
||||||
|
placeholder="17.25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={saveTc} disabled={tcSaving}>
|
||||||
|
<Save size={16} className="mr-2" />
|
||||||
|
{tcSaving ? 'Guardando...' : 'Actualizar TC'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Configuracion;
|
||||||
262
frontend-homenest/src/pages/Cortes.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Plus, Lock, Receipt } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
PageHeader,
|
||||||
|
Skeleton,
|
||||||
|
MobileCard,
|
||||||
|
toast,
|
||||||
|
badgeForCashClosingState,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type CashClosing } from '../services/odoo';
|
||||||
|
|
||||||
|
const Cortes: FC = () => {
|
||||||
|
const [closings, setClosings] = useState<CashClosing[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [closeOpen, setCloseOpen] = useState<CashClosing | null>(null);
|
||||||
|
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||||
|
const [openingCash, setOpeningCash] = useState('');
|
||||||
|
const [closingCash, setClosingCash] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await odooApi.getCashClosings();
|
||||||
|
if (res.status === 'success') setClosings(res.cash_closings);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar cortes');
|
||||||
|
toast.error('Error al cargar cortes');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
const value = parseFloat(openingCash);
|
||||||
|
if (Number.isNaN(value) || value < 0) {
|
||||||
|
toast.error('Ingresa un efectivo inicial válido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await odooApi.createCashClosing({ date, opening_cash: value });
|
||||||
|
toast.success('Caja abierta');
|
||||||
|
setCreateOpen(false);
|
||||||
|
setOpeningCash('');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al crear corte');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
if (!closeOpen) return;
|
||||||
|
const value = parseFloat(closingCash);
|
||||||
|
if (Number.isNaN(value) || value < 0) {
|
||||||
|
toast.error('Ingresa un efectivo final válido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await odooApi.closeCashClosing(closeOpen.id, value);
|
||||||
|
toast.success('Caja cerrada');
|
||||||
|
setCloseOpen(null);
|
||||||
|
setClosingCash('');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cerrar caja');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderUser = (user: string | false) => {
|
||||||
|
if (!user) return 'Usuario no disponible';
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const differenceColor = (diff: number) => {
|
||||||
|
if (diff > 0) return 'text-#3E2C1C';
|
||||||
|
if (diff < 0) return 'text-rose-600';
|
||||||
|
return 'text-[#7A5C44]';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Cortes de Caja" subtitle="Arqueos y cierres">
|
||||||
|
<PageHeader title="Cortes de Caja" subtitle="Abre y cierra turnos">
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Abrir caja
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={5} className="h-14 w-full" />
|
||||||
|
) : closings.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Sin cortes"
|
||||||
|
subtitle="No hay cortes de caja registrados."
|
||||||
|
actionLabel="Abrir caja"
|
||||||
|
onAction={() => setCreateOpen(true)}
|
||||||
|
icon={<Receipt size={28} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Referencia</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Fecha</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden md:table-cell">Usuario</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Apertura</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden lg:table-cell">Ventas</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Diferencia</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Estado</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{closings.map((c) => (
|
||||||
|
<tr key={c.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44]">{c.name}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44]">{c.date}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden md:table-cell">{renderUser(c.user)}</td>
|
||||||
|
<td className="p-3 text-sm text-homenest-bark">${c.opening_cash}</td>
|
||||||
|
<td className="p-3 text-sm text-homenest-bark hidden lg:table-cell">${c.total_sales}</td>
|
||||||
|
<td className={`p-3 text-sm font-semibold ${differenceColor(c.difference)}`}>
|
||||||
|
{c.difference > 0 ? '+' : ''}${c.difference}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant={badgeForCashClosingState(c.state)}>{c.state}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{c.state !== 'closed' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setCloseOpen(c)} title="Cerrar caja">
|
||||||
|
<Lock size={16} className="text-homenest-bark" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{closings.map((c) => (
|
||||||
|
<MobileCard
|
||||||
|
key={c.id}
|
||||||
|
title={c.name}
|
||||||
|
subtitle={<Badge variant={badgeForCashClosingState(c.state)}>{c.state}</Badge>}
|
||||||
|
rows={[
|
||||||
|
{ label: 'Fecha', value: c.date },
|
||||||
|
{ label: 'Usuario', value: renderUser(c.user) },
|
||||||
|
{ label: 'Apertura', value: `$${c.opening_cash}` },
|
||||||
|
{ label: 'Diferencia', value: `${c.difference > 0 ? '+' : ''}$${c.difference}` },
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
c.state !== 'closed' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setCloseOpen(c)}>
|
||||||
|
<Lock size={16} className="text-homenest-bark" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
title="Abrir caja"
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={create} loading={submitting}>Abrir caja</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Fecha"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Efectivo inicial"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={openingCash}
|
||||||
|
onChange={(e) => setOpeningCash(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!closeOpen}
|
||||||
|
onClose={() => setCloseOpen(null)}
|
||||||
|
title="Cerrar caja"
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setCloseOpen(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={close} loading={submitting}>Cerrar caja</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{closeOpen && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
Corte: <span className="font-medium text-homenest-bark">{closeOpen.name}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
Ventas registradas: <span className="font-medium text-homenest-bark">${closeOpen.total_sales}</span>
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Efectivo final"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={closingCash}
|
||||||
|
onChange={(e) => setClosingCash(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Cortes;
|
||||||
167
frontend-homenest/src/pages/Cumpleanos.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Cake, MessageCircle, Star, Download } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, PageHeader, Button, toast } from '../components/ui';
|
||||||
|
import { odooApi } from '../services/odoo';
|
||||||
|
import type { Birthday } from '../services/odoo';
|
||||||
|
import { downloadCsv } from '../lib/utils';
|
||||||
|
|
||||||
|
type Period = 'today' | 'week' | 'month';
|
||||||
|
|
||||||
|
const PERIODS: { value: Period; label: string }[] = [
|
||||||
|
{ value: 'today', label: 'Hoy' },
|
||||||
|
{ value: 'week', label: 'Próximos 7 días' },
|
||||||
|
{ value: 'month', label: 'Este mes' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fmtMoney = (n: number) =>
|
||||||
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const fmtDate = (s: string | null) => {
|
||||||
|
if (!s) return '—';
|
||||||
|
const d = new Date(`${s}T12:00:00`);
|
||||||
|
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const Cumpleanos: FC = () => {
|
||||||
|
const [period, setPeriod] = useState<Period>('month');
|
||||||
|
const [items, setItems] = useState<Birthday[]>([]);
|
||||||
|
const [range, setRange] = useState<{ start: string; end: string } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
odooApi
|
||||||
|
.getBirthdays(period)
|
||||||
|
.then((res) => {
|
||||||
|
if (!active) return;
|
||||||
|
setItems(res.birthdays || []);
|
||||||
|
setRange({ start: res.start, end: res.end });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setItems([]);
|
||||||
|
})
|
||||||
|
.finally(() => active && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
const exportBirthdays = () => {
|
||||||
|
const rows: (string | number | boolean | null | undefined)[][] = [
|
||||||
|
['Nombre', 'Teléfono', 'Fecha nacimiento', 'Cumple el', 'Cumple años', 'Última visita', 'Total gastado', 'Adeudo', 'VIP'],
|
||||||
|
];
|
||||||
|
items.forEach((b) =>
|
||||||
|
rows.push([
|
||||||
|
b.name, b.phone, b.birth_date, b.occurs_on, b.turning_age, b.last_visit,
|
||||||
|
b.total_spent, b.amount_due, b.is_vip ? 'Sí' : 'No',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
downloadCsv(`cumpleaneros-skeen-${period}-${new Date().toISOString().split('T')[0]}.csv`, rows);
|
||||||
|
toast.success(`CSV descargado (${items.length} cumpleañeros)`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Cumpleañeros" subtitle="Pacientes que cumplen años">
|
||||||
|
<PageHeader title="Cumpleañeros" subtitle="Pacientes que cumplen años en el periodo">
|
||||||
|
<Button variant="outline" onClick={exportBirthdays} disabled={items.length === 0}>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Exportar CSV
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card className="mb-4">
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
onClick={() => setPeriod(p.value)}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium transition ${
|
||||||
|
period === p.value
|
||||||
|
? 'bg-homenest-bark text-white'
|
||||||
|
: 'bg-[#FEF3C7] text-[#7A5C44] hover:bg-[#F5EBD8]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{range && (
|
||||||
|
<span className="ml-auto text-xs text-[#A87B5D]">
|
||||||
|
{range.start} → {range.end} · {items.length} pacientes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body noPadding>
|
||||||
|
{loading ? (
|
||||||
|
<p className="p-6 text-sm text-[#7A5C44]">Cargando...</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-[#A87B5D]">
|
||||||
|
<Cake className="mx-auto mb-2" size={28} />
|
||||||
|
<p className="text-sm">No hay cumpleañeros en este periodo.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[#FEF3C7] text-[#7A5C44] text-xs uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Fecha</th>
|
||||||
|
<th className="text-left px-4 py-3">Paciente</th>
|
||||||
|
<th className="text-left px-4 py-3">Cumple</th>
|
||||||
|
<th className="text-left px-4 py-3">Última visita</th>
|
||||||
|
<th className="text-right px-4 py-3">Total gastado</th>
|
||||||
|
<th className="text-right px-4 py-3">Adeudo</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#F5EBD8]">
|
||||||
|
{items.map((b) => (
|
||||||
|
<tr key={b.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="px-4 py-3 font-medium text-homenest-bark">{fmtDate(b.occurs_on)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{b.is_vip && <Star size={13} className="text-amber-500 fill-amber-500" />}
|
||||||
|
<span className="text-homenest-bark">{b.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[#A87B5D]">{b.phone}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[#7A5C44]">{b.turning_age} años</td>
|
||||||
|
<td className="px-4 py-3 text-[#7A5C44]">{fmtDate(b.last_visit)}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-homenest-bark">{fmtMoney(b.total_spent)}</td>
|
||||||
|
<td className={`px-4 py-3 text-right ${b.amount_due > 0 ? 'text-rose-600' : 'text-[#A87B5D]'}`}>
|
||||||
|
{fmtMoney(b.amount_due)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{b.phone && (
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/${b.phone.replace(/\D/g, '')}?text=${encodeURIComponent(
|
||||||
|
`¡Feliz cumpleaños ${b.name.split(' ')[0]}! 🎉 Te esperamos en SKEEN Derma Experts.`
|
||||||
|
)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-#3E2C1C hover:text-#3E2C1C text-xs font-medium"
|
||||||
|
>
|
||||||
|
<MessageCircle size={14} /> WhatsApp
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Cumpleanos;
|
||||||
535
frontend-homenest/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
DollarSign,
|
||||||
|
ShoppingCart,
|
||||||
|
Clock,
|
||||||
|
Wallet,
|
||||||
|
Sparkles,
|
||||||
|
Activity,
|
||||||
|
TrendingUp,
|
||||||
|
ArrowRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
PageHeader,
|
||||||
|
SkeletonStat,
|
||||||
|
Skeleton,
|
||||||
|
toast,
|
||||||
|
badgeForAppointmentState,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint } from '../services/odoo';
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) =>
|
||||||
|
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: ReactNode;
|
||||||
|
trend?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard: FC<StatCardProps> = ({ title, value, icon, trend, loading }) => {
|
||||||
|
if (loading) return <SkeletonStat />;
|
||||||
|
return (
|
||||||
|
<Card className="hover:shadow-md transition">
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[#D9F99D]/50 flex items-center justify-center text-homenest-bark">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
{trend && (
|
||||||
|
<span className="text-xs font-medium text-homenest-bark bg-[#D9F99D] px-2 py-0.5 rounded-full">
|
||||||
|
{trend}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#7A5C44] mb-1">{title}</p>
|
||||||
|
<p className="text-2xl font-heading font-semibold text-homenest-bark">{value}</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dashboard: FC = () => {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [chartData, setChartData] = useState<ChartPoint[]>([]);
|
||||||
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [loadingStats, setLoadingStats] = useState(true);
|
||||||
|
const [loadingChart, setLoadingChart] = useState(true);
|
||||||
|
const [loadingAppointments, setLoadingAppointments] = useState(true);
|
||||||
|
const [loadingServices, setLoadingServices] = useState(true);
|
||||||
|
const [topServices, setTopServices] = useState<{ service: string; qty: number; revenue: number }[]>([]);
|
||||||
|
const [aptReport, setAptReport] = useState<{ total: number; by_state: Record<string, number> } | null>(null);
|
||||||
|
const [loadingExtras, setLoadingExtras] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingStats(true);
|
||||||
|
const res = await odooApi.getDashboard();
|
||||||
|
if (res.status === 'success' && res.stats) {
|
||||||
|
setStats(res.stats);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cargar estadísticas del dashboard');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoadingStats(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadChart = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingChart(true);
|
||||||
|
const res = await odooApi.getWeeklyChart();
|
||||||
|
if (res.status === 'success' && res.data) {
|
||||||
|
setChartData(res.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cargar gráfica semanal');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoadingChart(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAppointments = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingAppointments(true);
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const res = await odooApi.getAppointments({ date: today, limit: '5' });
|
||||||
|
if (res.status === 'success' && res.appointments) {
|
||||||
|
setAppointments(res.appointments.slice(0, 5));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cargar próximas citas');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoadingAppointments(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadServices = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingServices(true);
|
||||||
|
const res = await odooApi.getServices();
|
||||||
|
if (res.status === 'success' && res.services) {
|
||||||
|
setServices(res.services.slice(0, 6));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cargar servicios');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoadingServices(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadExtras = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingExtras(true);
|
||||||
|
const start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
|
||||||
|
const end = new Date().toISOString().split('T')[0];
|
||||||
|
const [topRes, aptRes] = await Promise.all([
|
||||||
|
odooApi.getTopServices(start, end, 6),
|
||||||
|
odooApi.getAppointmentsReport(start, end),
|
||||||
|
]);
|
||||||
|
if (topRes.status === 'success') setTopServices(topRes.services || []);
|
||||||
|
if (aptRes.status === 'success') setAptReport({ total: aptRes.total, by_state: aptRes.by_state });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoadingExtras(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
loadChart();
|
||||||
|
loadAppointments();
|
||||||
|
loadServices();
|
||||||
|
loadExtras();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const statsConfig = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: 'Citas Hoy',
|
||||||
|
value: stats?.appointments_today ?? 0,
|
||||||
|
icon: <Calendar size={20} />,
|
||||||
|
trend: stats ? `${stats.appointments_month} este mes` : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pacientes',
|
||||||
|
value: stats?.total_patients ?? 0,
|
||||||
|
icon: <Users size={20} />,
|
||||||
|
trend: stats ? `+${stats.new_patients_month} nuevos` : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ingresos Mes',
|
||||||
|
value: stats ? formatCurrency(stats.revenue_month) : '$0',
|
||||||
|
icon: <DollarSign size={20} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ventas Hoy',
|
||||||
|
value: stats?.sales_today ?? 0,
|
||||||
|
icon: <ShoppingCart size={20} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pendientes',
|
||||||
|
value: stats?.pending_payments ?? 0,
|
||||||
|
icon: <Clock size={20} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Puntos Monedero',
|
||||||
|
value: stats?.total_wallet_points ?? 0,
|
||||||
|
icon: <Wallet size={20} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[stats]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activityItems = useMemo(() => {
|
||||||
|
return appointments.slice(0, 4).map((apt) => ({
|
||||||
|
id: apt.id,
|
||||||
|
title: `${apt.patient} — ${apt.service}`,
|
||||||
|
subtitle: `${apt.date} ${apt.time}`,
|
||||||
|
state: apt.state,
|
||||||
|
}));
|
||||||
|
}, [appointments]);
|
||||||
|
|
||||||
|
const attendanceData = useMemo(() => {
|
||||||
|
if (!aptReport) return [] as { name: string; value: number; color: string }[];
|
||||||
|
const s = aptReport.by_state || {};
|
||||||
|
const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0);
|
||||||
|
return [
|
||||||
|
{ name: 'Completadas', value: pick('done'), color: '#D9F99D' },
|
||||||
|
{ name: 'Confirmadas', value: pick('confirmed', 'arrived', 'in_progress'), color: '#E9D5B7' },
|
||||||
|
{ name: 'Pendientes', value: pick('pending'), color: '#8B5E3C' },
|
||||||
|
{ name: 'No show', value: pick('no_show'), color: '#E57373' },
|
||||||
|
{ name: 'Canceladas', value: pick('cancelled'), color: '#E9D5B7' },
|
||||||
|
].filter((d) => d.value > 0);
|
||||||
|
}, [aptReport]);
|
||||||
|
|
||||||
|
const noShowRate = useMemo(() => {
|
||||||
|
if (!aptReport || !aptReport.total) return 0;
|
||||||
|
return Math.round(((aptReport.by_state?.no_show || 0) / aptReport.total) * 100);
|
||||||
|
}, [aptReport]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Dashboard" subtitle="Panel de control de SKEEN Derma Experts">
|
||||||
|
<PageHeader title="Dashboard" subtitle="Resumen general de la clínica">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Sparkles size={16} className="mr-2" />
|
||||||
|
Bienvenido
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* Hero / welcome */}
|
||||||
|
<div className="bg-gradient-to-r from-[#8B5E3C] to-[#F3E2B3] rounded-2xl p-6 sm:p-8 text-white mb-6 sm:mb-8 shadow-sm">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-heading text-2xl sm:text-3xl mb-2">SKEEN Derma Experts</h1>
|
||||||
|
<p className="text-white/90 text-sm sm:text-base max-w-xl">
|
||||||
|
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Button variant="secondary" size="sm" className="bg-homenest-cream-light/20 text-white border-white/30 hover:bg-homenest-cream-light/30">
|
||||||
|
<TrendingUp size={16} className="mr-2" />
|
||||||
|
Ver reportes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-6 sm:mb-8">
|
||||||
|
{statsConfig.map((s) => (
|
||||||
|
<StatCard
|
||||||
|
key={s.title}
|
||||||
|
title={s.title}
|
||||||
|
value={s.value}
|
||||||
|
icon={s.icon}
|
||||||
|
trend={s.trend}
|
||||||
|
loading={loadingStats}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart + appointments */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg text-homenest-bark">Actividad semanal</h3>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Citas e ingresos de los últimos 7 días</p>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
{loadingChart ? (
|
||||||
|
<Skeleton count={8} className="h-6 w-full" />
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<EmptyState title="Sin datos" subtitle="No hay información para la gráfica semanal." icon={<TrendingUp size={28} />} />
|
||||||
|
) : (
|
||||||
|
<div className="h-[300px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorAppointments" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#8B5E3C" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#8B5E3C" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#D9F99D" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#D9F99D" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E9D5B7" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 12, fill: '#7A5C44' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 12, fill: '#7A5C44' }} axisLine={false} tickLine={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="appointments" stroke="#8B5E3C" fillOpacity={1} fill="url(#colorAppointments)" strokeWidth={2} />
|
||||||
|
<Area type="monotone" dataKey="revenue" stroke="#D9F99D" fillOpacity={1} fill="url(#colorRevenue)" strokeWidth={2} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<Card.Header>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg text-homenest-bark">Próximas citas</h3>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Citas programadas para hoy</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="hidden sm:inline-flex">
|
||||||
|
Ver agenda
|
||||||
|
<ArrowRight size={14} className="ml-1" />
|
||||||
|
</Button>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body className="flex-1">
|
||||||
|
{loadingAppointments ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : appointments.length === 0 ? (
|
||||||
|
<EmptyState title="Sin citas" subtitle="No hay citas programadas para hoy." icon={<Calendar size={28} />} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{appointments.map((apt) => (
|
||||||
|
<div
|
||||||
|
key={apt.id}
|
||||||
|
className="flex items-center justify-between p-3 border border-[#E9D5B7] rounded-xl hover:bg-[#FEF3C7] transition"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark truncate">{apt.patient}</p>
|
||||||
|
<p className="text-xs text-[#7A5C44] truncate">{apt.service} • {apt.time}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top servicios + Asistencia del mes */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg text-homenest-bark">Top servicios del mes</h3>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Por ingresos generados</p>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
{loadingExtras ? (
|
||||||
|
<Skeleton count={6} className="h-6 w-full" />
|
||||||
|
) : topServices.length === 0 ? (
|
||||||
|
<EmptyState title="Sin datos" subtitle="No hay ventas en el mes." icon={<TrendingUp size={28} />} />
|
||||||
|
) : (
|
||||||
|
<div className="h-[280px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={topServices} layout="vertical" margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#E9D5B7" />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11, fill: '#7A5C44' }} axisLine={false} tickLine={false} tickFormatter={(v: number) => formatCurrency(v)} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="service"
|
||||||
|
width={150}
|
||||||
|
tick={{ fontSize: 11, fill: '#3E2C1C' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: string) => (v.length > 22 ? `${v.slice(0, 22)}…` : v)}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v) => formatCurrency(Number(v))}
|
||||||
|
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="revenue" fill="#8B5E3C" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg text-homenest-bark">Asistencia del mes</h3>
|
||||||
|
<p className="text-xs text-[#7A5C44]">
|
||||||
|
{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
{loadingExtras ? (
|
||||||
|
<Skeleton count={6} className="h-6 w-full" />
|
||||||
|
) : attendanceData.length === 0 ? (
|
||||||
|
<EmptyState title="Sin datos" subtitle="No hay citas en el mes." icon={<Calendar size={28} />} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||||
|
<div className="h-[240px] w-full sm:w-1/2">
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={attendanceData} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={60} outerRadius={95} paddingAngle={2}>
|
||||||
|
{attendanceData.map((d, i) => (
|
||||||
|
<Cell key={`cell-${i}`} fill={d.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 w-full space-y-2">
|
||||||
|
{attendanceData.map((d) => (
|
||||||
|
<div key={d.name} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="flex items-center gap-2 text-[#7A5C44]">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: d.color }} />
|
||||||
|
{d.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-homenest-bark">{d.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services + activity */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg text-homenest-bark">Servicios destacados</h3>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Catálogo activo de tratamientos</p>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
{loadingServices ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : services.length === 0 ? (
|
||||||
|
<EmptyState title="Sin servicios" subtitle="No hay servicios registrados." icon={<Sparkles size={28} />} />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{services.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center gap-3 p-3 border border-[#E9D5B7] rounded-xl hover:bg-[#FEF3C7] transition"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg shrink-0"
|
||||||
|
style={{ backgroundColor: s.color || '#78716c' }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark truncate">{s.name}</p>
|
||||||
|
<p className="text-xs text-[#7A5C44]">${s.price} • {s.duration_min} min</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg text-homenest-bark">Actividad reciente</h3>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Últimos movimientos en la clínica</p>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
{loadingAppointments ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : activityItems.length === 0 ? (
|
||||||
|
<EmptyState title="Sin actividad" subtitle="No hay movimientos recientes." icon={<Activity size={28} />} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activityItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start gap-3 p-3 border border-[#E9D5B7] rounded-xl hover:bg-[#FEF3C7] transition"
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-[#F5EBD8] flex items-center justify-center text-[#7A5C44] shrink-0 mt-0.5">
|
||||||
|
<Activity size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark truncate">{item.title}</p>
|
||||||
|
<p className="text-xs text-[#7A5C44]">{item.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={badgeForAppointmentState(item.state)} className="shrink-0">
|
||||||
|
{item.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
315
frontend-homenest/src/pages/Inventario.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, Button, Input, Select, Modal, PageHeader, toast } from '../components/ui';
|
||||||
|
import { odooApi } from '../services/odoo';
|
||||||
|
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary } from '../services/odoo';
|
||||||
|
|
||||||
|
const fmtMoney = (n: number) =>
|
||||||
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const LEVEL_META: Record<InventoryLevel, { label: string; cls: string }> = {
|
||||||
|
out: { label: 'Sin existencias', cls: 'bg-rose-100 text-rose-700' },
|
||||||
|
critical: { label: 'Crítico', cls: 'bg-orange-100 text-orange-700' },
|
||||||
|
low: { label: 'Bajo', cls: 'bg-#FEF3C7 text-#92400E' },
|
||||||
|
optimal: { label: 'Óptimo', cls: 'bg-#D9F99D text-#3E2C1C' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOVE_META: Record<InventoryMoveType, { label: string; sign: string; cls: string }> = {
|
||||||
|
compra: { label: 'Compra', sign: '+', cls: 'text-#3E2C1C' },
|
||||||
|
venta: { label: 'Venta', sign: '−', cls: 'text-[#7A5C44]' },
|
||||||
|
baja: { label: 'Baja', sign: '−', cls: 'text-rose-600' },
|
||||||
|
ajuste: { label: 'Ajuste', sign: '=', cls: 'text-sky-600' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_OPTIONS = [
|
||||||
|
{ value: '', label: 'Todos' },
|
||||||
|
{ value: 'producto', label: 'Productos' },
|
||||||
|
{ value: 'consumible', label: 'Consumibles' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS = [
|
||||||
|
{ value: '', label: 'Todos los niveles' },
|
||||||
|
{ value: 'optimal', label: 'Óptimo' },
|
||||||
|
{ value: 'low', label: 'Bajo' },
|
||||||
|
{ value: 'critical', label: 'Crítico' },
|
||||||
|
{ value: 'out', label: 'Sin existencias' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOVE_OPTIONS = [
|
||||||
|
{ value: 'compra', label: 'Compra (entrada)' },
|
||||||
|
{ value: 'venta', label: 'Venta (salida)' },
|
||||||
|
{ value: 'baja', label: 'Baja (merma)' },
|
||||||
|
{ value: 'ajuste', label: 'Ajuste (fijar conteo físico)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyItem: Partial<InventoryItem> = {
|
||||||
|
name: '', kind: 'producto', sku: '', category: '', unit: 'pieza',
|
||||||
|
qty: 0, qty_optimal: 0, qty_min: 0, cost: 0, expiry_date: null, notes: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Inventario: FC = () => {
|
||||||
|
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||||
|
const [summary, setSummary] = useState<InventorySummary>({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [kind, setKind] = useState<string>('');
|
||||||
|
const [level, setLevel] = useState<string>('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
|
const [newForm, setNewForm] = useState<Partial<InventoryItem>>(emptyItem);
|
||||||
|
const [savingNew, setSavingNew] = useState(false);
|
||||||
|
|
||||||
|
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
|
||||||
|
const [moveType, setMoveType] = useState<InventoryMoveType>('compra');
|
||||||
|
const [moveQty, setMoveQty] = useState('');
|
||||||
|
const [moveRef, setMoveRef] = useState('');
|
||||||
|
const [savingMove, setSavingMove] = useState(false);
|
||||||
|
|
||||||
|
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null);
|
||||||
|
const [moves, setMoves] = useState<InventoryMove[]>([]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getInventory({
|
||||||
|
...(kind ? { kind: kind as InventoryKind } : {}),
|
||||||
|
...(level ? { level: level as InventoryLevel } : {}),
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
});
|
||||||
|
setItems(res.items || []);
|
||||||
|
setSummary(res.summary);
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo cargar el inventario');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [kind, level, search]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const createItem = async () => {
|
||||||
|
if (!newForm.name) { toast.error('El nombre es obligatorio'); return; }
|
||||||
|
setSavingNew(true);
|
||||||
|
try {
|
||||||
|
await odooApi.createInventoryItem(newForm);
|
||||||
|
toast.success('Item creado');
|
||||||
|
setNewOpen(false);
|
||||||
|
setNewForm(emptyItem);
|
||||||
|
load();
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al crear el item');
|
||||||
|
} finally {
|
||||||
|
setSavingNew(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAdjust = (it: InventoryItem) => {
|
||||||
|
setAdjustItem(it);
|
||||||
|
setMoveType('compra');
|
||||||
|
setMoveQty('');
|
||||||
|
setMoveRef('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitMove = async () => {
|
||||||
|
if (!adjustItem) return;
|
||||||
|
const q = parseFloat(moveQty);
|
||||||
|
if (isNaN(q) || q < 0) { toast.error('Cantidad inválida'); return; }
|
||||||
|
setSavingMove(true);
|
||||||
|
try {
|
||||||
|
await odooApi.createInventoryMove(adjustItem.id, { type: moveType, qty: q, reference: moveRef });
|
||||||
|
toast.success('Movimiento registrado');
|
||||||
|
setAdjustItem(null);
|
||||||
|
load();
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al registrar el movimiento');
|
||||||
|
} finally {
|
||||||
|
setSavingMove(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openHistory = async (it: InventoryItem) => {
|
||||||
|
setHistoryItem(it);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getInventoryMoves(it.id);
|
||||||
|
setMoves(res.moves || []);
|
||||||
|
} catch {
|
||||||
|
setMoves([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lvl = summary.levels;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Inventario" subtitle="Productos y consumibles">
|
||||||
|
<PageHeader
|
||||||
|
title="Inventario y consumibles"
|
||||||
|
subtitle="Existencias, niveles y movimientos (compras, ventas, bajas, ajustes)"
|
||||||
|
>
|
||||||
|
<Button onClick={() => setNewOpen(true)}>
|
||||||
|
<Plus size={16} className="mr-2" /> Nuevo item
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* KPIs */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4">
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Valor total</p>
|
||||||
|
<p className="text-xl font-semibold text-homenest-bark">{fmtMoney(summary.total_value)}</p>
|
||||||
|
<p className="text-xs text-[#A87B5D]">{summary.count} items</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<div className="flex items-center gap-2"><PackageCheck size={16} className="text-#3E2C1C" /><p className="text-xs text-[#7A5C44]">Óptimo</p></div>
|
||||||
|
<p className="text-xl font-semibold text-#3E2C1C">{lvl.optimal}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<div className="flex items-center gap-2"><AlertTriangle size={16} className="text-amber-600" /><p className="text-xs text-[#7A5C44]">Bajo / Crítico</p></div>
|
||||||
|
<p className="text-xl font-semibold text-#92400E">{lvl.low + lvl.critical}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<div className="flex items-center gap-2"><Boxes size={16} className="text-rose-600" /><p className="text-xs text-[#7A5C44]">Sin existencias</p></div>
|
||||||
|
<p className="text-xl font-semibold text-rose-700">{lvl.out}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtros */}
|
||||||
|
<Card className="mb-4"><Card.Body>
|
||||||
|
<div className="flex flex-col lg:flex-row gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{KIND_OPTIONS.map((k) => (
|
||||||
|
<button key={k.value} onClick={() => setKind(k.value)}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium transition ${kind === k.value ? 'bg-homenest-bark text-white' : 'bg-[#FEF3C7] text-[#7A5C44] hover:bg-[#F5EBD8]'}`}>
|
||||||
|
{k.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<Input placeholder="Buscar por nombre, SKU o categoría" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full lg:w-56">
|
||||||
|
<Select options={LEVEL_OPTIONS} value={level} onChange={(e) => setLevel(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Body></Card>
|
||||||
|
|
||||||
|
{/* Tabla */}
|
||||||
|
<Card><Card.Body noPadding>
|
||||||
|
{loading ? (
|
||||||
|
<p className="p-6 text-sm text-[#7A5C44]">Cargando...</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-[#A87B5D]">
|
||||||
|
<Boxes className="mx-auto mb-2" size={28} />
|
||||||
|
<p className="text-sm">No hay items. Crea el primero con “Nuevo item”.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[#FEF3C7] text-[#7A5C44] text-xs uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Item</th>
|
||||||
|
<th className="text-left px-4 py-3">Tipo</th>
|
||||||
|
<th className="text-right px-4 py-3">Existencia</th>
|
||||||
|
<th className="text-left px-4 py-3">Nivel</th>
|
||||||
|
<th className="text-right px-4 py-3">Costo</th>
|
||||||
|
<th className="text-right px-4 py-3">Valor</th>
|
||||||
|
<th className="text-left px-4 py-3">Caducidad</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#F5EBD8]">
|
||||||
|
{items.map((it) => (
|
||||||
|
<tr key={it.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium text-homenest-bark">{it.name}</p>
|
||||||
|
<p className="text-xs text-[#A87B5D]">{[it.sku, it.category].filter(Boolean).join(' · ') || '—'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${it.kind === 'consumible' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'}`}>
|
||||||
|
{it.kind === 'consumible' ? 'Consumible' : 'Producto'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium text-homenest-bark">{it.qty} <span className="text-xs text-[#A87B5D]">{it.unit}</span></td>
|
||||||
|
<td className="px-4 py-3"><span className={`text-xs px-2 py-1 rounded-full ${LEVEL_META[it.stock_level].cls}`}>{LEVEL_META[it.stock_level].label}</span></td>
|
||||||
|
<td className="px-4 py-3 text-right text-[#7A5C44]">{fmtMoney(it.cost)}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-homenest-bark">{fmtMoney(it.inventory_value)}</td>
|
||||||
|
<td className="px-4 py-3 text-[#7A5C44]">{it.expiry_date || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openHistory(it)}><History size={14} /></Button>
|
||||||
|
<Button size="sm" onClick={() => openAdjust(it)}>Ajustar</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body></Card>
|
||||||
|
|
||||||
|
{/* Modal nuevo item */}
|
||||||
|
<Modal isOpen={newOpen} onClose={() => setNewOpen(false)} title="Nuevo item de inventario" maxWidth="lg"
|
||||||
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => setNewOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={createItem} loading={savingNew}>Guardar</Button>
|
||||||
|
</>}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="sm:col-span-2"><Input label="Nombre" value={newForm.name || ''} onChange={(e) => setNewForm({ ...newForm, name: e.target.value })} /></div>
|
||||||
|
<Select label="Tipo" options={[{ value: 'producto', label: 'Producto' }, { value: 'consumible', label: 'Consumible' }]} value={newForm.kind || 'producto'} onChange={(e) => setNewForm({ ...newForm, kind: e.target.value as InventoryKind })} />
|
||||||
|
<Input label="Unidad" value={newForm.unit || ''} onChange={(e) => setNewForm({ ...newForm, unit: e.target.value })} placeholder="pieza / caja / vial" />
|
||||||
|
<Input label="SKU / Código" value={newForm.sku || ''} onChange={(e) => setNewForm({ ...newForm, sku: e.target.value })} />
|
||||||
|
<Input label="Línea / Categoría" value={newForm.category || ''} onChange={(e) => setNewForm({ ...newForm, category: e.target.value })} />
|
||||||
|
<Input label="Existencia inicial" type="number" min="0" step="0.01" value={String(newForm.qty ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty: parseFloat(e.target.value) || 0 })} />
|
||||||
|
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(newForm.cost ?? 0)} onChange={(e) => setNewForm({ ...newForm, cost: parseFloat(e.target.value) || 0 })} />
|
||||||
|
<Input label="Stock mínimo (crítico)" type="number" min="0" step="0.01" value={String(newForm.qty_min ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_min: parseFloat(e.target.value) || 0 })} />
|
||||||
|
<Input label="Nivel óptimo" type="number" min="0" step="0.01" value={String(newForm.qty_optimal ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_optimal: parseFloat(e.target.value) || 0 })} />
|
||||||
|
{newForm.kind === 'consumible' && (
|
||||||
|
<Input label="Caducidad" type="date" value={newForm.expiry_date || ''} onChange={(e) => setNewForm({ ...newForm, expiry_date: e.target.value || null })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal ajustar / movimiento */}
|
||||||
|
<Modal isOpen={!!adjustItem} onClose={() => setAdjustItem(null)} title={adjustItem ? `Ajustar: ${adjustItem.name}` : 'Ajustar'} maxWidth="md"
|
||||||
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => setAdjustItem(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={submitMove} loading={savingMove}>Registrar</Button>
|
||||||
|
</>}>
|
||||||
|
{adjustItem && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[#7A5C44]">Existencia actual: <span className="font-semibold text-homenest-bark">{adjustItem.qty} {adjustItem.unit}</span></p>
|
||||||
|
<Select label="Tipo de movimiento" options={MOVE_OPTIONS} value={moveType} onChange={(e) => setMoveType(e.target.value as InventoryMoveType)} />
|
||||||
|
<Input label={moveType === 'ajuste' ? 'Nuevo conteo (existencia final)' : 'Cantidad'} type="number" min="0" step="0.01" value={moveQty} onChange={(e) => setMoveQty(e.target.value)} />
|
||||||
|
<Input label="Referencia (opcional)" value={moveRef} onChange={(e) => setMoveRef(e.target.value)} placeholder="OC, folio, nota..." />
|
||||||
|
{moveType === 'ajuste' && <p className="text-xs text-sky-700 bg-sky-50 rounded-lg p-2">El ajuste fija la existencia al valor capturado (conteo físico).</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal historial */}
|
||||||
|
<Modal isOpen={!!historyItem} onClose={() => setHistoryItem(null)} title={historyItem ? `Movimientos: ${historyItem.name}` : 'Movimientos'} maxWidth="lg">
|
||||||
|
{moves.length === 0 ? (
|
||||||
|
<p className="text-sm text-[#A87B5D]">Sin movimientos registrados.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{moves.map((m) => (
|
||||||
|
<div key={m.id} className="flex items-center justify-between p-3 bg-[#FEF3C7] rounded-xl text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{m.type === 'compra' ? <ArrowDownToLine size={16} className="text-#3E2C1C" /> : <ArrowUpFromLine size={16} className="text-[#7A5C44]" />}
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${MOVE_META[m.type].cls}`}>{MOVE_META[m.type].label} {MOVE_META[m.type].sign}{m.qty}</p>
|
||||||
|
<p className="text-xs text-[#A87B5D]">{m.date}{m.reference ? ` · ${m.reference}` : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#7A5C44]">{m.before_qty} → <span className="font-semibold text-homenest-bark">{m.after_qty}</span></p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Inventario;
|
||||||
88
frontend-homenest/src/pages/Login.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { FC, FormEvent } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { LogIn } from 'lucide-react';
|
||||||
|
import { Button, Input } from '../components/ui';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
|
||||||
|
const LOGO_BROWN = '/skeen-brand/logos/Logo%20Completo%20Negro.png';
|
||||||
|
|
||||||
|
const Login: FC = () => {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
setError('Escribe usuario y contraseña');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await login(username.trim(), password);
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'No se pudo iniciar sesión');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-homenest-cream flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
{/* Círculos decorativos de la paleta */}
|
||||||
|
<div className="absolute top-[-10%] right-[-5%] w-72 h-72 rounded-full bg-homenest-brown/20 blur-3xl" />
|
||||||
|
<div className="absolute bottom-[-10%] left-[-5%] w-80 h-80 rounded-full bg-homenest-sage/30 blur-3xl" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 rounded-full bg-homenest-sand/40 blur-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full max-w-sm relative z-10">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<img
|
||||||
|
src={LOGO_BROWN}
|
||||||
|
alt="SKEEN Derma Experts"
|
||||||
|
className="h-10 w-auto object-contain mx-auto mb-6"
|
||||||
|
/>
|
||||||
|
<h1 className="font-display text-3xl text-homenest-bark mb-2 tracking-wide">Bienvenido</h1>
|
||||||
|
<p className="text-sm text-homenest-bark-muted">Inicia sesión para continuar</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="bg-homenest-cream-light border border-homenest-sand rounded-3xl p-7 space-y-5 shadow-card">
|
||||||
|
<Input
|
||||||
|
label="Usuario"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
inputClassName="bg-homenest-cream border-homenest-sand text-homenest-bark placeholder:text-homenest-bark-muted/60 focus:border-homenest-brown focus:ring-homenest-brown/30"
|
||||||
|
labelClassName="text-homenest-bark-muted"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Contraseña"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
inputClassName="bg-homenest-cream border-homenest-sand text-homenest-bark placeholder:text-homenest-bark-muted/60 focus:border-homenest-brown focus:ring-homenest-brown/30"
|
||||||
|
labelClassName="text-homenest-bark-muted"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-sm text-homenest-rose">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full" loading={submitting}>
|
||||||
|
<LogIn size={16} className="mr-2" />
|
||||||
|
Entrar
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-homenest-bark-muted/60 mt-6">
|
||||||
|
SKEEN Derma Experts · Playas de Rosarito, B.C.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
170
frontend-homenest/src/pages/Medicos.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, toast } from '../components/ui';
|
||||||
|
import { odooApi, type Doctor } from '../services/odoo';
|
||||||
|
|
||||||
|
const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico'];
|
||||||
|
|
||||||
|
const isMedicalTitle = (jobTitle?: string) => {
|
||||||
|
if (!jobTitle) return false;
|
||||||
|
const normalized = jobTitle.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
return medicalTerms.some((term) => normalized.includes(term));
|
||||||
|
};
|
||||||
|
|
||||||
|
const Medicos: FC = () => {
|
||||||
|
const [employees, setEmployees] = useState<Doctor[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await odooApi.getDoctors();
|
||||||
|
if (res.status === 'success') setEmployees(res.doctors);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar médicos');
|
||||||
|
toast.error('Error al cargar médicos');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doctors = useMemo(() => {
|
||||||
|
const medical = employees.filter((d) => isMedicalTitle(d.job_title));
|
||||||
|
return medical.length > 0 ? medical : employees;
|
||||||
|
}, [employees]);
|
||||||
|
|
||||||
|
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
|
||||||
|
const [savingPct, setSavingPct] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const pctValue = (d: Doctor) => (pctDraft[d.id] ?? String(d.commission_pct ?? 0));
|
||||||
|
|
||||||
|
const savePct = async (d: Doctor) => {
|
||||||
|
const raw = pctDraft[d.id];
|
||||||
|
const pct = parseFloat(raw ?? String(d.commission_pct ?? 0));
|
||||||
|
if (Number.isNaN(pct) || pct < 0 || pct > 100) {
|
||||||
|
toast.error('Porcentaje inválido (0-100)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingPct(d.id);
|
||||||
|
try {
|
||||||
|
await odooApi.updateDoctor(d.id, { commission_pct: pct });
|
||||||
|
setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, commission_pct: pct } : e)));
|
||||||
|
setPctDraft((prev) => { const n = { ...prev }; delete n[d.id]; return n; });
|
||||||
|
toast.success(`Comisión de ${d.name}: ${pct}%`);
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo guardar la comisión');
|
||||||
|
} finally {
|
||||||
|
setSavingPct(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Médicos" subtitle="Equipo médico">
|
||||||
|
<PageHeader title="Médicos" subtitle="Especialistas de la clínica" />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={5} className="h-14 w-full" />
|
||||||
|
) : doctors.length === 0 ? (
|
||||||
|
<EmptyState title="Sin médicos" subtitle="No se encontraron médicos en el equipo." icon={<Stethoscope size={28} />} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Nombre</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Puesto</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden md:table-cell">Teléfono</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden lg:table-cell">Email</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">% Comisión</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{doctors.map((d) => (
|
||||||
|
<tr key={d.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">{d.name}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant="default" className="inline-flex items-center">
|
||||||
|
<Briefcase size={12} className="mr-1" />
|
||||||
|
{d.job_title || 'Sin puesto'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden md:table-cell">
|
||||||
|
<a href={`tel:${d.work_phone}`} className="hover:text-homenest-bark">
|
||||||
|
<Phone size={14} className="inline mr-1 text-[#A87B5D]" />
|
||||||
|
{d.work_phone || '-'}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden lg:table-cell">
|
||||||
|
{d.work_email ? (
|
||||||
|
<a href={`mailto:${d.work_email}`} className="hover:text-homenest-bark">
|
||||||
|
<Mail size={14} className="inline mr-1 text-[#A87B5D]" />
|
||||||
|
{d.work_email}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-2 max-w-[170px]">
|
||||||
|
<Percent size={14} className="text-[#A87B5D] shrink-0" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.5"
|
||||||
|
value={pctValue(d)}
|
||||||
|
onChange={(e) => setPctDraft((prev) => ({ ...prev, [d.id]: e.target.value }))}
|
||||||
|
className="max-w-[92px]"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline" loading={savingPct === d.id} onClick={() => savePct(d)} title="Guardar %">
|
||||||
|
<Save size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{doctors.map((d) => (
|
||||||
|
<MobileCard
|
||||||
|
key={d.id}
|
||||||
|
title={d.name}
|
||||||
|
subtitle={
|
||||||
|
<Badge variant="default" className="inline-flex items-center">
|
||||||
|
<Briefcase size={12} className="mr-1" />
|
||||||
|
{d.job_title || 'Sin puesto'}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
rows={[
|
||||||
|
{ label: 'Teléfono', value: d.work_phone || '-' },
|
||||||
|
{ label: 'Email', value: d.work_email || '-' },
|
||||||
|
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Medicos;
|
||||||
265
frontend-homenest/src/pages/Monedero.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Search, Plus, Minus, Wallet } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
TextArea,
|
||||||
|
Modal,
|
||||||
|
EmptyState,
|
||||||
|
PageHeader,
|
||||||
|
Skeleton,
|
||||||
|
MobileCard,
|
||||||
|
toast,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type Wallet as WalletType } from '../services/odoo';
|
||||||
|
|
||||||
|
type TxType = 'accrual' | 'redemption';
|
||||||
|
|
||||||
|
const Monedero: FC = () => {
|
||||||
|
const [wallets, setWallets] = useState<WalletType[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<WalletType | null>(null);
|
||||||
|
const [txType, setTxType] = useState<TxType>('accrual');
|
||||||
|
const [points, setPoints] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await odooApi.getWallets({ search, page, page_size: pageSize });
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setWallets(res.wallets);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
setTotalPages(res.total_pages ?? 0);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar monederos');
|
||||||
|
toast.error('Error al cargar monederos');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [search, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const openModal = (w: WalletType, type: TxType) => {
|
||||||
|
setSelected(w);
|
||||||
|
setTxType(type);
|
||||||
|
setPoints('');
|
||||||
|
setDescription('');
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
const value = parseFloat(points);
|
||||||
|
if (Number.isNaN(value) || value <= 0) {
|
||||||
|
toast.error('Ingresa una cantidad válida de puntos');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (txType === 'redemption' && value > selected.points) {
|
||||||
|
toast.error('No puede redimir más puntos de los disponibles');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await odooApi.walletTransaction({
|
||||||
|
phone: selected.phone,
|
||||||
|
type: txType,
|
||||||
|
points: value,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
toast.success(txType === 'accrual' ? 'Puntos acumulados' : 'Puntos redimidos');
|
||||||
|
setModalOpen(false);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error en transacción');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Monedero" subtitle="Puntos y recompensas">
|
||||||
|
<PageHeader title="Monedero" subtitle="Busca por teléfono y gestiona puntos" />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||||
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#A87B5D]" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por teléfono..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={5} className="h-14 w-full" />
|
||||||
|
) : wallets.length === 0 ? (
|
||||||
|
<EmptyState title="Sin monederos" subtitle="No se encontraron monederos." icon={<Wallet size={28} />} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Paciente</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Teléfono</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Puntos</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Equivalente MXN</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{wallets.map((w) => (
|
||||||
|
<tr key={w.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">{w.patient}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44]">{w.phone}</td>
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">{w.points} pts</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44]">${w.equivalent_mxn}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openModal(w, 'accrual')}
|
||||||
|
title="Acumular"
|
||||||
|
>
|
||||||
|
<Plus size={16} className="text-#3E2C1C" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openModal(w, 'redemption')}
|
||||||
|
title="Redimir"
|
||||||
|
disabled={w.points <= 0}
|
||||||
|
>
|
||||||
|
<Minus size={16} className="text-rose-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{wallets.map((w) => (
|
||||||
|
<MobileCard
|
||||||
|
key={w.id}
|
||||||
|
title={w.patient}
|
||||||
|
subtitle={w.phone}
|
||||||
|
rows={[
|
||||||
|
{ label: 'Puntos', value: `${w.points} pts` },
|
||||||
|
{ label: 'Equivalente', value: `$${w.equivalent_mxn}` },
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openModal(w, 'accrual')}>
|
||||||
|
<Plus size={16} className="text-#3E2C1C" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openModal(w, 'redemption')} disabled={w.points <= 0}>
|
||||||
|
<Minus size={16} className="text-rose-600" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paginación */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-[#F5EBD8]">
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
{total} monederos · página {page} de {totalPages || 1}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
|
||||||
|
className="border border-[#E9D5B7] rounded-lg px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={200}>200</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
title={txType === 'accrual' ? 'Acumular puntos' : 'Redimir puntos'}
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={submit} loading={submitting}>Guardar</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selected && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
Paciente: <span className="font-medium text-homenest-bark">{selected.patient}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
Puntos disponibles: <span className="font-medium text-homenest-bark">{selected.points} pts</span>
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Puntos *"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={points}
|
||||||
|
onChange={(e) => setPoints(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
label="Descripción"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Monedero;
|
||||||
758
frontend-homenest/src/pages/Pacientes.tsx
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
|
||||||
|
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
|
||||||
|
Download,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Modal,
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
MobileCard,
|
||||||
|
PageHeader,
|
||||||
|
Skeleton,
|
||||||
|
toast,
|
||||||
|
TextArea,
|
||||||
|
badgeForAppointmentState,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type Patient, type Appointment, type Sale } from '../services/odoo';
|
||||||
|
import { downloadCsv } from '../lib/utils';
|
||||||
|
|
||||||
|
const genderOptions = [
|
||||||
|
{ value: '', label: 'No especificado' },
|
||||||
|
{ value: 'male', label: 'Masculino' },
|
||||||
|
{ value: 'female', label: 'Femenino' },
|
||||||
|
{ value: 'other', label: 'Otro' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const bloodOptions = [
|
||||||
|
{ value: '', label: 'No especificado' },
|
||||||
|
{ value: 'a+', label: 'A+' },
|
||||||
|
{ value: 'a-', label: 'A-' },
|
||||||
|
{ value: 'b+', label: 'B+' },
|
||||||
|
{ value: 'b-', label: 'B-' },
|
||||||
|
{ value: 'ab+', label: 'AB+' },
|
||||||
|
{ value: 'ab-', label: 'AB-' },
|
||||||
|
{ value: 'o+', label: 'O+' },
|
||||||
|
{ value: 'o-', label: 'O-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const maritalOptions = [
|
||||||
|
{ value: '', label: 'No especificado' },
|
||||||
|
{ value: 'single', label: 'Soltero/a' },
|
||||||
|
{ value: 'married', label: 'Casado/a' },
|
||||||
|
{ value: 'divorced', label: 'Divorciado/a' },
|
||||||
|
{ value: 'widowed', label: 'Viudo/a' },
|
||||||
|
{ value: 'union', label: 'Unión libre' },
|
||||||
|
{ value: 'other', label: 'Otro' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatGender = (gender?: string | false) => {
|
||||||
|
if (!gender) return 'No especificado';
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
male: 'Masculino',
|
||||||
|
female: 'Femenino',
|
||||||
|
other: 'Otro',
|
||||||
|
};
|
||||||
|
return map[gender] || gender;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMarital = (status?: string) => {
|
||||||
|
if (!status) return 'No especificado';
|
||||||
|
const opt = maritalOptions.find((o) => o.value === status);
|
||||||
|
return opt?.label || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBlood = (bt?: string | false) => {
|
||||||
|
if (!bt || typeof bt !== 'string') return 'No especificado';
|
||||||
|
const opt = bloodOptions.find((o) => o.value === bt.toLowerCase());
|
||||||
|
return opt?.label || bt.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const Pacientes: FC = () => {
|
||||||
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [selected, setSelected] = useState<Patient | null>(null);
|
||||||
|
const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[] } | null>(null);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [tab, setTab] = useState<'info' | 'clinical' | 'appointments' | 'sales'>('info');
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Patient | null>(null);
|
||||||
|
|
||||||
|
const emptyForm: PatientForm = {
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
birth_date: '',
|
||||||
|
gender: '',
|
||||||
|
blood_type: '',
|
||||||
|
birthplace: '',
|
||||||
|
occupation: '',
|
||||||
|
marital_status: '',
|
||||||
|
emergency_contact: '',
|
||||||
|
emergency_phone: '',
|
||||||
|
home_phone: '',
|
||||||
|
mobile: '',
|
||||||
|
address_notes: '',
|
||||||
|
referred_by: '',
|
||||||
|
patient_comments: '',
|
||||||
|
internal_notes: '',
|
||||||
|
allergies: '',
|
||||||
|
medical_history: '',
|
||||||
|
current_medication: '',
|
||||||
|
medical_notes: '',
|
||||||
|
surgeries_notes: '',
|
||||||
|
children_count: 0,
|
||||||
|
is_pregnant: false,
|
||||||
|
is_breastfeeding: false,
|
||||||
|
uses_contraceptives: false,
|
||||||
|
kidney_problems: false,
|
||||||
|
back_pain: false,
|
||||||
|
heart_disease: false,
|
||||||
|
respiratory_problems: false,
|
||||||
|
blood_pressure: false,
|
||||||
|
diabetes: false,
|
||||||
|
thyroid: false,
|
||||||
|
colitis: false,
|
||||||
|
constipation: false,
|
||||||
|
liver_problems: false,
|
||||||
|
surgeries: false,
|
||||||
|
varicose_veins: false,
|
||||||
|
migraine: false,
|
||||||
|
faints_with_needles: false,
|
||||||
|
};
|
||||||
|
const [form, setForm] = useState<PatientForm>(emptyForm);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await odooApi.getPatients({ search, page, page_size: pageSize });
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setPatients(res.patients);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
setTotalPages(res.total_pages ?? 0);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar pacientes');
|
||||||
|
toast.error('Error al cargar pacientes');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [search, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const openHistory = async (patient: Patient) => {
|
||||||
|
setSelected(patient);
|
||||||
|
setTab('info');
|
||||||
|
setHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getPatientHistory(patient.id);
|
||||||
|
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cargar historial');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (patient: Patient) => {
|
||||||
|
setEditing(patient);
|
||||||
|
setForm({
|
||||||
|
...emptyForm,
|
||||||
|
...patient,
|
||||||
|
birth_date: patient.birth_date || '',
|
||||||
|
gender: patient.gender || '',
|
||||||
|
blood_type: patient.blood_type || '',
|
||||||
|
marital_status: patient.marital_status || '',
|
||||||
|
children_count: patient.children_count ?? 0,
|
||||||
|
is_pregnant: patient.is_pregnant ?? false,
|
||||||
|
is_breastfeeding: patient.is_breastfeeding ?? false,
|
||||||
|
uses_contraceptives: patient.uses_contraceptives ?? false,
|
||||||
|
kidney_problems: patient.kidney_problems ?? false,
|
||||||
|
back_pain: patient.back_pain ?? false,
|
||||||
|
heart_disease: patient.heart_disease ?? false,
|
||||||
|
respiratory_problems: patient.respiratory_problems ?? false,
|
||||||
|
blood_pressure: patient.blood_pressure ?? false,
|
||||||
|
diabetes: patient.diabetes ?? false,
|
||||||
|
thyroid: patient.thyroid ?? false,
|
||||||
|
colitis: patient.colitis ?? false,
|
||||||
|
constipation: patient.constipation ?? false,
|
||||||
|
liver_problems: patient.liver_problems ?? false,
|
||||||
|
surgeries: patient.surgeries ?? false,
|
||||||
|
varicose_veins: patient.varicose_veins ?? false,
|
||||||
|
migraine: patient.migraine ?? false,
|
||||||
|
faints_with_needles: patient.faints_with_needles ?? false,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateForm = (patch: Partial<PatientForm>) => {
|
||||||
|
setForm((prev) => ({ ...prev, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!form.name || !form.phone) {
|
||||||
|
toast.error('Nombre y teléfono son obligatorios');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const payload: Partial<Patient> = {
|
||||||
|
...form,
|
||||||
|
birth_date: form.birth_date || null,
|
||||||
|
gender: form.gender || undefined,
|
||||||
|
blood_type: form.blood_type || undefined,
|
||||||
|
marital_status: form.marital_status || undefined,
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await odooApi.updatePatient(editing.id, payload);
|
||||||
|
toast.success('Paciente actualizado');
|
||||||
|
} else {
|
||||||
|
await odooApi.createPatient(payload);
|
||||||
|
toast.success('Paciente creado');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(editing ? 'Error al actualizar paciente' : 'Error al crear paciente');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TableHeader = () => (
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Nombre</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden sm:table-cell">Teléfono</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden md:table-cell">Edad</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden md:table-cell">Última visita</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Visitas</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Puntos</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InfoRow = ({ label, value, icon: Icon }: { label: string; value: React.ReactNode; icon?: typeof User }) => (
|
||||||
|
<div className="p-3 bg-[#FEF3C7] rounded-xl">
|
||||||
|
<p className="text-xs text-[#7A5C44] mb-1">{label}</p>
|
||||||
|
<p className="text-sm font-medium text-homenest-bark flex items-start">
|
||||||
|
{Icon && <Icon size={14} className="mr-1.5 mt-0.5 text-[#A87B5D] shrink-0" />}
|
||||||
|
<span className="break-words">{value || '-'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ClinicalItem = ({ label, value }: { label: string; value?: boolean }) => (
|
||||||
|
<div className="flex items-center justify-between p-2.5 bg-homenest-cream-light border border-[#F5EBD8] rounded-lg">
|
||||||
|
<span className="text-sm text-homenest-bark">{label}</span>
|
||||||
|
{value ? (
|
||||||
|
<CheckCircle2 size={16} className="text-#3E2C1C" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={16} className="text-[#E9D5B7]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportPatients = () => {
|
||||||
|
const rows: (string | number | boolean | null | undefined)[][] = [
|
||||||
|
['Expediente', 'Nombre', 'Teléfono', 'Email', 'Fecha nacimiento', 'Género', 'Última visita', 'Visitas', 'Puntos monedero', 'Total gastado', 'Fuente', 'VIP'],
|
||||||
|
];
|
||||||
|
patients.forEach((p) =>
|
||||||
|
rows.push([
|
||||||
|
p.patient_id, p.name, p.phone, p.email, p.birth_date, p.gender || '', p.last_visit,
|
||||||
|
p.total_visits, p.wallet_points, p.total_spent, p.source, p.is_vip ? 'Sí' : 'No',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
downloadCsv(`pacientes-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
|
||||||
|
toast.success(`CSV descargado (${patients.length} pacientes de la vista actual)`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Pacientes" subtitle="Directorio de pacientes">
|
||||||
|
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
|
||||||
|
<Button variant="outline" onClick={exportPatients} disabled={patients.length === 0}>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Exportar CSV
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Nuevo paciente
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||||
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#A87B5D]" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por nombre o teléfono..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={6} className="h-12 w-full" />
|
||||||
|
) : patients.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Sin pacientes"
|
||||||
|
subtitle="No se encontraron pacientes."
|
||||||
|
actionLabel="Nuevo paciente"
|
||||||
|
onAction={openCreate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<TableHeader />
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{patients.map((p) => (
|
||||||
|
<tr key={p.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">
|
||||||
|
<button onClick={() => openHistory(p)} className="text-left hover:underline">
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden sm:table-cell">{p.phone}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden md:table-cell">{p.age ?? '-'}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden md:table-cell">{p.last_visit || '-'}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44]">{p.total_visits}</td>
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">{p.wallet_points} pts</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openHistory(p)} title="Ver detalle">
|
||||||
|
<User size={16} className="text-[#7A5C44]" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(p)} title="Editar">
|
||||||
|
<Edit2 size={16} className="text-[#7A5C44]" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{patients.map((p) => (
|
||||||
|
<MobileCard
|
||||||
|
key={p.id}
|
||||||
|
title={p.name}
|
||||||
|
subtitle={p.phone}
|
||||||
|
rows={[
|
||||||
|
{ label: 'Edad', value: p.age ?? '-' },
|
||||||
|
{ label: 'Última visita', value: p.last_visit || '-' },
|
||||||
|
{ label: 'Visitas', value: p.total_visits },
|
||||||
|
{ label: 'Puntos', value: `${p.wallet_points} pts` },
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openHistory(p)}>
|
||||||
|
<User size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onClick={() => openHistory(p)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paginación */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-[#F5EBD8]">
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
{total} pacientes · página {page} de {totalPages || 1}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
|
||||||
|
className="border border-[#E9D5B7] rounded-lg px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={200}>200</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Modal crear/editar */}
|
||||||
|
<Modal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
title={editing ? 'Editar paciente' : 'Nuevo paciente'}
|
||||||
|
maxWidth="2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={save} loading={submitting}>{editing ? 'Actualizar' : 'Crear'}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
<section>
|
||||||
|
<h4 className="text-sm font-semibold text-homenest-bark mb-3 flex items-center">
|
||||||
|
<User size={16} className="mr-2 text-[#7A5C44]" /> Datos generales
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Input label="Nombre *" value={form.name} onChange={(e) => updateForm({ name: e.target.value })} />
|
||||||
|
<Input label="Teléfono *" value={form.phone} onChange={(e) => updateForm({ phone: e.target.value })} />
|
||||||
|
<Input label="Email" type="email" value={form.email} onChange={(e) => updateForm({ email: e.target.value })} />
|
||||||
|
<Input label="Celular" value={form.mobile} onChange={(e) => updateForm({ mobile: e.target.value })} />
|
||||||
|
<Input label="Teléfono casa" value={form.home_phone} onChange={(e) => updateForm({ home_phone: e.target.value })} />
|
||||||
|
<Input label="Fecha de nacimiento" type="date" value={form.birth_date} onChange={(e) => updateForm({ birth_date: e.target.value })} />
|
||||||
|
<Select label="Género" options={genderOptions} value={form.gender} onChange={(e) => updateForm({ gender: e.target.value })} />
|
||||||
|
<Select label="Tipo sanguíneo" options={bloodOptions} value={form.blood_type} onChange={(e) => updateForm({ blood_type: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="text-sm font-semibold text-homenest-bark mb-3 flex items-center">
|
||||||
|
<MapPin size={16} className="mr-2 text-[#7A5C44]" /> Datos personales
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Input label="Lugar de nacimiento" value={form.birthplace} onChange={(e) => updateForm({ birthplace: e.target.value })} />
|
||||||
|
<Input label="Empleo / Ocupación" value={form.occupation} onChange={(e) => updateForm({ occupation: e.target.value })} />
|
||||||
|
<Select label="Estado civil" options={maritalOptions} value={form.marital_status} onChange={(e) => updateForm({ marital_status: e.target.value })} />
|
||||||
|
<Input label="Recomendado por" value={form.referred_by} onChange={(e) => updateForm({ referred_by: e.target.value })} />
|
||||||
|
<Input label="Contacto de emergencia" value={form.emergency_contact} onChange={(e) => updateForm({ emergency_contact: e.target.value })} />
|
||||||
|
<Input label="Teléfono de emergencia" value={form.emergency_phone} onChange={(e) => updateForm({ emergency_phone: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<TextArea label="Dirección completa" value={form.address_notes} onChange={(e) => updateForm({ address_notes: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="text-sm font-semibold text-homenest-bark mb-3 flex items-center">
|
||||||
|
<Baby size={16} className="mr-2 text-[#7A5C44]" /> Antecedentes gineco-obstétricos
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-[#E9D5B7] rounded-xl cursor-pointer hover:bg-[#FEF3C7]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.is_pregnant}
|
||||||
|
onChange={(e) => updateForm({ is_pregnant: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-[#E9D5B7] text-homenest-bark focus:ring-[#8B5E3C]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-homenest-bark">¿Está embarazada?</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-[#E9D5B7] rounded-xl cursor-pointer hover:bg-[#FEF3C7]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.is_breastfeeding}
|
||||||
|
onChange={(e) => updateForm({ is_breastfeeding: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-[#E9D5B7] text-homenest-bark focus:ring-[#8B5E3C]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-homenest-bark">¿Está lactando?</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-[#E9D5B7] rounded-xl cursor-pointer hover:bg-[#FEF3C7]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.uses_contraceptives}
|
||||||
|
onChange={(e) => updateForm({ uses_contraceptives: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-[#E9D5B7] text-homenest-bark focus:ring-[#8B5E3C]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-homenest-bark">¿Usa anticonceptivos?</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
label="Número de hijos"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={String(form.children_count ?? 0)}
|
||||||
|
onChange={(e) => updateForm({ children_count: parseInt(e.target.value || '0', 10) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="text-sm font-semibold text-homenest-bark mb-3 flex items-center">
|
||||||
|
<Stethoscope size={16} className="mr-2 text-[#7A5C44]" /> Historia clínica
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
['kidney_problems', 'Problemas de riñón'],
|
||||||
|
['back_pain', 'Dolor de espalda'],
|
||||||
|
['heart_disease', 'Enfermedades cardíacas'],
|
||||||
|
['respiratory_problems', 'Problemas respiratorios'],
|
||||||
|
['blood_pressure', 'Presión arterial'],
|
||||||
|
['diabetes', 'Diabetes'],
|
||||||
|
['thyroid', 'Problemas de tiroides'],
|
||||||
|
['colitis', 'Colitis'],
|
||||||
|
['constipation', 'Estreñimiento'],
|
||||||
|
['liver_problems', 'Problemas de hígado'],
|
||||||
|
['surgeries', 'Cirugías'],
|
||||||
|
['varicose_veins', 'Varices'],
|
||||||
|
['migraine', 'Migraña'],
|
||||||
|
['faints_with_needles', 'Se desmaya con agujas'],
|
||||||
|
].map(([key, label]) => {
|
||||||
|
const checked = !!form[key as keyof PatientForm];
|
||||||
|
return (
|
||||||
|
<label key={key} className="flex items-center gap-3 p-3 border border-[#E9D5B7] rounded-xl cursor-pointer hover:bg-[#FEF3C7]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={typeof checked === 'boolean' ? checked : false}
|
||||||
|
onChange={(e) => updateForm({ [key]: e.target.checked } as Partial<PatientForm>)}
|
||||||
|
className="w-4 h-4 rounded border-[#E9D5B7] text-homenest-bark focus:ring-[#8B5E3C]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-homenest-bark">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 mt-4">
|
||||||
|
<TextArea label="Alergias" value={form.allergies} onChange={(e) => updateForm({ allergies: e.target.value })} />
|
||||||
|
<TextArea label="Historial médico" value={form.medical_history} onChange={(e) => updateForm({ medical_history: e.target.value })} />
|
||||||
|
<TextArea label="Medicación actual" value={form.current_medication} onChange={(e) => updateForm({ current_medication: e.target.value })} />
|
||||||
|
<TextArea label="Detalle de cirugías" value={form.surgeries_notes} onChange={(e) => updateForm({ surgeries_notes: e.target.value })} />
|
||||||
|
<TextArea label="Notas médicas adicionales" value={form.medical_notes} onChange={(e) => updateForm({ medical_notes: e.target.value })} />
|
||||||
|
<TextArea label="Comentarios del paciente" value={form.patient_comments} onChange={(e) => updateForm({ patient_comments: e.target.value })} />
|
||||||
|
<TextArea label="Notas internas (solo equipo, no visible para el paciente)" value={form.internal_notes} onChange={(e) => updateForm({ internal_notes: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal detalle */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
title={selected?.name || 'Detalle del paciente'}
|
||||||
|
maxWidth="2xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2 border-b border-[#F5EBD8] pb-2 overflow-x-auto">
|
||||||
|
{([
|
||||||
|
{ key: 'info', label: 'Información general' },
|
||||||
|
{ key: 'clinical', label: 'Historia clínica' },
|
||||||
|
{ key: 'appointments', label: 'Citas' },
|
||||||
|
{ key: 'sales', label: 'Ventas' },
|
||||||
|
] as const).map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-lg whitespace-nowrap ${
|
||||||
|
tab === key ? 'bg-homenest-bark text-white' : 'text-[#7A5C44] hover:bg-[#FEF3C7]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{historyLoading ? (
|
||||||
|
<Skeleton count={4} className="h-16 w-full" />
|
||||||
|
) : tab === 'info' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
<InfoRow label="Folio" value={selected?.patient_id} icon={FileText} />
|
||||||
|
<InfoRow label="Teléfono" value={selected?.phone} icon={Phone} />
|
||||||
|
<InfoRow label="Celular" value={selected?.mobile} icon={Phone} />
|
||||||
|
<InfoRow label="Email" value={selected?.email} icon={Mail} />
|
||||||
|
<InfoRow label="Teléfono casa" value={selected?.home_phone} icon={Phone} />
|
||||||
|
<InfoRow label="Fecha de nacimiento" value={selected?.birth_date} icon={Calendar} />
|
||||||
|
<InfoRow label="Edad" value={selected?.age} icon={Calendar} />
|
||||||
|
<InfoRow label="Género" value={formatGender(selected?.gender)} icon={User} />
|
||||||
|
<InfoRow label="Tipo sanguíneo" value={formatBlood(selected?.blood_type)} icon={Droplet} />
|
||||||
|
<InfoRow label="Lugar de nacimiento" value={selected?.birthplace} icon={MapPin} />
|
||||||
|
<InfoRow label="Empleo / Ocupación" value={selected?.occupation} icon={Briefcase} />
|
||||||
|
<InfoRow label="Estado civil" value={formatMarital(selected?.marital_status)} icon={Users} />
|
||||||
|
<InfoRow label="Contacto de emergencia" value={selected?.emergency_contact} icon={AlertCircle} />
|
||||||
|
<InfoRow label="Tel. emergencia" value={selected?.emergency_phone} icon={Phone} />
|
||||||
|
<InfoRow label="Recomendado por" value={selected?.referred_by} icon={Users} />
|
||||||
|
<InfoRow label="Puntos" value={`${selected?.wallet_points ?? 0} pts`} icon={Heart} />
|
||||||
|
</div>
|
||||||
|
{selected?.address_notes && (
|
||||||
|
<div className="p-3 bg-[#FEF3C7] rounded-xl">
|
||||||
|
<p className="text-xs text-[#7A5C44] mb-1">Dirección</p>
|
||||||
|
<p className="text-sm text-homenest-bark whitespace-pre-wrap">{selected.address_notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected?.patient_comments && (
|
||||||
|
<div className="p-3 bg-[#FEF3C7] rounded-xl">
|
||||||
|
<p className="text-xs text-[#7A5C44] mb-1">Comentarios</p>
|
||||||
|
<p className="text-sm text-homenest-bark whitespace-pre-wrap">{selected.patient_comments}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected?.internal_notes && (
|
||||||
|
<div className="p-3 bg-amber-50 border border-#FEF3C7 rounded-xl">
|
||||||
|
<p className="text-xs text-#92400E mb-1">🔒 Notas internas (solo equipo)</p>
|
||||||
|
<p className="text-sm text-homenest-bark whitespace-pre-wrap">{selected.internal_notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : tab === 'clinical' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
<ClinicalItem label="Embarazo" value={selected?.is_pregnant} />
|
||||||
|
<ClinicalItem label="Lactancia" value={selected?.is_breastfeeding} />
|
||||||
|
<ClinicalItem label="Anticonceptivos" value={selected?.uses_contraceptives} />
|
||||||
|
<ClinicalItem label="Problemas de riñón" value={selected?.kidney_problems} />
|
||||||
|
<ClinicalItem label="Dolor de espalda" value={selected?.back_pain} />
|
||||||
|
<ClinicalItem label="Enfermedades cardíacas" value={selected?.heart_disease} />
|
||||||
|
<ClinicalItem label="Problemas respiratorios" value={selected?.respiratory_problems} />
|
||||||
|
<ClinicalItem label="Presión arterial" value={selected?.blood_pressure} />
|
||||||
|
<ClinicalItem label="Diabetes" value={selected?.diabetes} />
|
||||||
|
<ClinicalItem label="Problemas de tiroides" value={selected?.thyroid} />
|
||||||
|
<ClinicalItem label="Colitis" value={selected?.colitis} />
|
||||||
|
<ClinicalItem label="Estreñimiento" value={selected?.constipation} />
|
||||||
|
<ClinicalItem label="Problemas de hígado" value={selected?.liver_problems} />
|
||||||
|
<ClinicalItem label="Cirugías" value={selected?.surgeries} />
|
||||||
|
<ClinicalItem label="Varices" value={selected?.varicose_veins} />
|
||||||
|
<ClinicalItem label="Migraña" value={selected?.migraine} />
|
||||||
|
<ClinicalItem label="Se desmaya con agujas" value={selected?.faints_with_needles} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<InfoRow label="Número de hijos" value={selected?.children_count} />
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
{ label: 'Alergias', value: selected?.allergies },
|
||||||
|
{ label: 'Historial médico', value: selected?.medical_history },
|
||||||
|
{ label: 'Medicación actual', value: selected?.current_medication },
|
||||||
|
{ label: 'Detalle de cirugías', value: selected?.surgeries_notes },
|
||||||
|
{ label: 'Notas médicas adicionales', value: selected?.medical_notes },
|
||||||
|
].map(({ label, value }) =>
|
||||||
|
value ? (
|
||||||
|
<div key={label} className="p-3 bg-[#FEF3C7] rounded-xl">
|
||||||
|
<p className="text-xs text-[#7A5C44] mb-1">{label}</p>
|
||||||
|
<p className="text-sm text-homenest-bark whitespace-pre-wrap">{value}</p>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : tab === 'appointments' ? (
|
||||||
|
history && history.appointments.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{history.appointments.map((a) => (
|
||||||
|
<div key={a.id} className="p-3 border border-[#F5EBD8] rounded-xl text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-medium text-homenest-bark">
|
||||||
|
{a.date} {a.time}
|
||||||
|
</p>
|
||||||
|
<Badge variant={badgeForAppointmentState(a.state)}>{a.state}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7A5C44] mt-1">{a.service}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Sin citas" subtitle="Este paciente no tiene citas registradas." icon={<Activity size={28} />} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
history && history.sales.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{history.sales.map((s) => (
|
||||||
|
<div key={s.id} className="p-3 border border-[#F5EBD8] rounded-xl text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-medium text-homenest-bark">{s.name}</p>
|
||||||
|
<p className="font-semibold text-homenest-bark">${s.total}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[#7A5C44] mt-1">{s.date} — {s.state}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Sin ventas" subtitle="Este paciente no tiene ventas registradas." icon={<ShoppingBag size={28} />} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PatientForm {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
birth_date: string;
|
||||||
|
gender: string;
|
||||||
|
blood_type: string;
|
||||||
|
birthplace: string;
|
||||||
|
occupation: string;
|
||||||
|
marital_status: string;
|
||||||
|
emergency_contact: string;
|
||||||
|
emergency_phone: string;
|
||||||
|
home_phone: string;
|
||||||
|
mobile: string;
|
||||||
|
address_notes: string;
|
||||||
|
referred_by: string;
|
||||||
|
patient_comments: string;
|
||||||
|
internal_notes: string;
|
||||||
|
allergies: string;
|
||||||
|
medical_history: string;
|
||||||
|
current_medication: string;
|
||||||
|
medical_notes: string;
|
||||||
|
surgeries_notes: string;
|
||||||
|
children_count: number;
|
||||||
|
is_pregnant: boolean;
|
||||||
|
is_breastfeeding: boolean;
|
||||||
|
uses_contraceptives: boolean;
|
||||||
|
kidney_problems: boolean;
|
||||||
|
back_pain: boolean;
|
||||||
|
heart_disease: boolean;
|
||||||
|
respiratory_problems: boolean;
|
||||||
|
blood_pressure: boolean;
|
||||||
|
diabetes: boolean;
|
||||||
|
thyroid: boolean;
|
||||||
|
colitis: boolean;
|
||||||
|
constipation: boolean;
|
||||||
|
liver_problems: boolean;
|
||||||
|
surgeries: boolean;
|
||||||
|
varicose_veins: boolean;
|
||||||
|
migraine: boolean;
|
||||||
|
faints_with_needles: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pacientes;
|
||||||
223
frontend-homenest/src/pages/Pagos.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Plus, CheckCircle, CreditCard } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Modal,
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
PageHeader,
|
||||||
|
Skeleton,
|
||||||
|
MobileCard,
|
||||||
|
toast,
|
||||||
|
badgeForPaymentState,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type Payment, type Patient } from '../services/odoo';
|
||||||
|
|
||||||
|
const methods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago', 'Stripe'];
|
||||||
|
|
||||||
|
const methodOptions = methods.map((m) => ({ value: m, label: m }));
|
||||||
|
|
||||||
|
const Pagos: FC = () => {
|
||||||
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [patient, setPatient] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [method, setMethod] = useState('Efectivo');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [paymentsRes, patientsRes] = await Promise.all([
|
||||||
|
odooApi.getPayments(),
|
||||||
|
odooApi.getPatients(),
|
||||||
|
]);
|
||||||
|
if (paymentsRes.status === 'success') setPayments(paymentsRes.payments);
|
||||||
|
if (patientsRes.status === 'success') setPatients(patientsRes.patients);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar pagos');
|
||||||
|
toast.error('Error al cargar pagos');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
const value = parseFloat(amount);
|
||||||
|
if (!patient || Number.isNaN(value) || value <= 0) {
|
||||||
|
toast.error('Selecciona paciente e ingresa un monto válido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await odooApi.createPayment({ patient, amount: value, payment_method: method });
|
||||||
|
toast.success('Pago creado');
|
||||||
|
setModalOpen(false);
|
||||||
|
setPatient('');
|
||||||
|
setAmount('');
|
||||||
|
setMethod('Efectivo');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al crear pago');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await odooApi.confirmPayment(id);
|
||||||
|
toast.success('Pago confirmado');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al confirmar pago');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patientOptions = [
|
||||||
|
{ value: '', label: 'Seleccionar paciente...' },
|
||||||
|
...patients.map((p) => ({ value: p.name, label: `${p.name} (${p.phone})` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Pagos" subtitle="Transacciones">
|
||||||
|
<PageHeader title="Pagos" subtitle="Crea y confirma pagos">
|
||||||
|
<Button onClick={() => setModalOpen(true)}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Nuevo pago
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={6} className="h-12 w-full" />
|
||||||
|
) : payments.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Sin pagos"
|
||||||
|
subtitle="No hay pagos registrados."
|
||||||
|
actionLabel="Nuevo pago"
|
||||||
|
onAction={() => setModalOpen(true)}
|
||||||
|
icon={<CreditCard size={28} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Referencia</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Paciente</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Monto</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden sm:table-cell">Método</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Estado</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{payments.map((p) => (
|
||||||
|
<tr key={p.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44]">{p.name}</td>
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">{p.patient}</td>
|
||||||
|
<td className="p-3 text-sm font-semibold text-homenest-bark">${p.amount}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden sm:table-cell">{p.payment_method}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant={badgeForPaymentState(p.state)}>{p.state}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{p.state !== 'confirmed' && p.state !== 'posted' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)} title="Confirmar">
|
||||||
|
<CheckCircle size={16} className="text-#3E2C1C" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{payments.map((p) => (
|
||||||
|
<MobileCard
|
||||||
|
key={p.id}
|
||||||
|
title={p.patient}
|
||||||
|
subtitle={p.name}
|
||||||
|
rows={[
|
||||||
|
{ label: 'Monto', value: `$${p.amount}` },
|
||||||
|
{ label: 'Método', value: p.payment_method },
|
||||||
|
{ label: 'Estado', value: <Badge variant={badgeForPaymentState(p.state)}>{p.state}</Badge> },
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
p.state !== 'confirmed' && p.state !== 'posted' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)}>
|
||||||
|
<CheckCircle size={16} className="text-#3E2C1C" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
title="Nuevo pago"
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={create} loading={submitting}>Crear pago</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Select
|
||||||
|
label="Paciente *"
|
||||||
|
options={patientOptions}
|
||||||
|
value={patient}
|
||||||
|
onChange={(e) => setPatient(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Monto *"
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step={0.01}
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Método de pago"
|
||||||
|
options={methodOptions}
|
||||||
|
value={method}
|
||||||
|
onChange={(e) => setMethod(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Pagos;
|
||||||
129
frontend-homenest/src/pages/Productos.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Package, Search } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, Input, Badge, EmptyState, PageHeader, Skeleton, MobileCard, toast } from '../components/ui';
|
||||||
|
import { odooApi, type Product } from '../services/odoo';
|
||||||
|
|
||||||
|
const translateType = (type: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
product: 'Producto almacenable',
|
||||||
|
service: 'Servicio',
|
||||||
|
consu: 'Consumible',
|
||||||
|
};
|
||||||
|
return map[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Productos: FC = () => {
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [filtered, setFiltered] = useState<Product[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await odooApi.getProducts();
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setProducts(res.products);
|
||||||
|
setFiltered(res.products);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar productos');
|
||||||
|
toast.error('Error al cargar productos');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = search.toLowerCase();
|
||||||
|
setFiltered(
|
||||||
|
products.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(term) ||
|
||||||
|
(p.default_code && p.default_code.toLowerCase().includes(term)) ||
|
||||||
|
p.type.toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [search, products]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Productos" subtitle="Inventario">
|
||||||
|
<PageHeader title="Productos" subtitle="Catálogo de productos" />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||||
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#A87B5D]" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por nombre, código o tipo..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={6} className="h-12 w-full" />
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<EmptyState title="Sin productos" subtitle="No se encontraron productos." icon={<Package size={28} />} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Referencia</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Nombre</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Tipo</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Precio</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{filtered.map((p) => (
|
||||||
|
<tr key={p.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44]">{p.default_code || '-'}</td>
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">{p.name}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant="default">{translateType(p.type)}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm font-semibold text-homenest-bark">${p.list_price}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{filtered.map((p) => (
|
||||||
|
<MobileCard
|
||||||
|
key={p.id}
|
||||||
|
title={p.name}
|
||||||
|
subtitle={<Badge variant="default">{translateType(p.type)}</Badge>}
|
||||||
|
rows={[
|
||||||
|
{ label: 'Referencia', value: p.default_code || '-' },
|
||||||
|
{ label: 'Precio', value: `$${p.list_price}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Productos;
|
||||||
302
frontend-homenest/src/pages/Reportes.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Download, BarChart3 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
} from 'recharts';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
|
||||||
|
import { odooApi } from '../services/odoo';
|
||||||
|
import type { CommissionRow } from '../services/odoo';
|
||||||
|
import { downloadCsv } from '../lib/utils';
|
||||||
|
|
||||||
|
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
|
||||||
|
|
||||||
|
interface SalesReport {
|
||||||
|
total_sales: number;
|
||||||
|
total_paid: number;
|
||||||
|
total_due: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppointmentsReport {
|
||||||
|
total: number;
|
||||||
|
by_state: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CashReport {
|
||||||
|
total: number;
|
||||||
|
by_method: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Reportes: FC = () => {
|
||||||
|
const [start, setStart] = useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(1);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
});
|
||||||
|
const [end, setEnd] = useState(() => new Date().toISOString().split('T')[0]);
|
||||||
|
const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]);
|
||||||
|
const [salesReport, setSalesReport] = useState<SalesReport | null>(null);
|
||||||
|
const [appointmentsReport, setAppointmentsReport] = useState<AppointmentsReport | null>(null);
|
||||||
|
const [cashReport, setCashReport] = useState<CashReport | null>(null);
|
||||||
|
const [commissions, setCommissions] = useState<CommissionRow[]>([]);
|
||||||
|
const [commTotals, setCommTotals] = useState<{ base: number; commission: number }>({ base: 0, commission: 0 });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [salesRes, aptRes, cashRes, commRes] = await Promise.all([
|
||||||
|
odooApi.getSalesReport(start, end),
|
||||||
|
odooApi.getAppointmentsReport(start, end),
|
||||||
|
odooApi.getCashReport(today),
|
||||||
|
odooApi.getCommissions(start, end),
|
||||||
|
]);
|
||||||
|
if (salesRes.status === 'success')
|
||||||
|
setSalesReport({
|
||||||
|
total_sales: salesRes.total_sales,
|
||||||
|
total_paid: salesRes.total_paid,
|
||||||
|
total_due: salesRes.total_due,
|
||||||
|
count: salesRes.count,
|
||||||
|
});
|
||||||
|
if (aptRes.status === 'success')
|
||||||
|
setAppointmentsReport({ total: aptRes.total, by_state: aptRes.by_state });
|
||||||
|
if (cashRes.status === 'success')
|
||||||
|
setCashReport({ total: cashRes.total, by_method: cashRes.by_method });
|
||||||
|
if (commRes.status === 'success') {
|
||||||
|
setCommissions(commRes.commissions || []);
|
||||||
|
setCommTotals({ base: commRes.total_base || 0, commission: commRes.total_commission || 0 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar reportes');
|
||||||
|
toast.error('Error al cargar reportes');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [start, end, today]);
|
||||||
|
|
||||||
|
const aptData = appointmentsReport
|
||||||
|
? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value }))
|
||||||
|
: [];
|
||||||
|
const cashData = cashReport
|
||||||
|
? Object.entries(cashReport.by_method).map(([name, value]) => ({ name, value }))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
const rows: (string | number)[][] = [];
|
||||||
|
rows.push(['Reporte SKEEN']);
|
||||||
|
rows.push(['Rango', `${start} a ${end}`]);
|
||||||
|
rows.push([]);
|
||||||
|
|
||||||
|
rows.push(['Ventas']);
|
||||||
|
rows.push(['Total', 'Pagado', 'Por cobrar', 'Cantidad']);
|
||||||
|
if (salesReport) {
|
||||||
|
rows.push([
|
||||||
|
salesReport.total_sales,
|
||||||
|
salesReport.total_paid,
|
||||||
|
salesReport.total_due,
|
||||||
|
salesReport.count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
rows.push([]);
|
||||||
|
|
||||||
|
rows.push(['Citas por estado']);
|
||||||
|
rows.push(['Estado', 'Cantidad']);
|
||||||
|
aptData.forEach((row) => rows.push([row.name, row.value]));
|
||||||
|
rows.push([]);
|
||||||
|
|
||||||
|
rows.push(['Efectivo por método']);
|
||||||
|
rows.push(['Método', 'Monto']);
|
||||||
|
cashData.forEach((row) => rows.push([row.name, row.value]));
|
||||||
|
rows.push([]);
|
||||||
|
|
||||||
|
rows.push(['Comisiones por médico']);
|
||||||
|
rows.push(['Médico', 'Puesto', '% Comisión', 'Artículos', 'Ventas', 'Base recetada', 'Comisión']);
|
||||||
|
commissions.forEach((c) =>
|
||||||
|
rows.push([c.doctor, c.job_title || '', c.commission_pct, c.items, c.sales, c.base, c.commission])
|
||||||
|
);
|
||||||
|
rows.push(['', '', '', '', 'Totales', commTotals.base, commTotals.commission]);
|
||||||
|
|
||||||
|
downloadCsv(`reporte-skeen-${start}_${end}.csv`, rows);
|
||||||
|
toast.success('CSV descargado');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
||||||
|
<PageHeader title="Reportes" subtitle="Análisis del mes y día">
|
||||||
|
<Button variant="outline" onClick={exportCSV} disabled={loading || !salesReport}>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Exportar CSV
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
|
||||||
|
<Input
|
||||||
|
label="Inicio"
|
||||||
|
type="date"
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => setStart(e.target.value)}
|
||||||
|
className="sm:max-w-[180px]"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Fin"
|
||||||
|
type="date"
|
||||||
|
value={end}
|
||||||
|
onChange={(e) => setEnd(e.target.value)}
|
||||||
|
className="sm:max-w-[180px]"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Día efectivo"
|
||||||
|
type="date"
|
||||||
|
value={today}
|
||||||
|
onChange={(e) => setToday(e.target.value)}
|
||||||
|
className="sm:max-w-[180px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-rose-600 text-sm mt-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : salesReport ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||||
|
<Card className="p-4 bg-[#FEF3C7] border-0">
|
||||||
|
<p className="text-xs text-[#7A5C44]">Ventas</p>
|
||||||
|
<p className="text-xl font-heading font-semibold text-homenest-bark">${salesReport.total_sales}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 bg-[#FEF3C7] border-0">
|
||||||
|
<p className="text-xs text-[#7A5C44]">Pagado</p>
|
||||||
|
<p className="text-xl font-heading font-semibold text-homenest-bark">${salesReport.total_paid}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 bg-[#FEF3C7] border-0">
|
||||||
|
<p className="text-xs text-[#7A5C44]">Por cobrar</p>
|
||||||
|
<p className="text-xl font-heading font-semibold text-homenest-bark">${salesReport.total_due}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 bg-[#FEF3C7] border-0">
|
||||||
|
<p className="text-xs text-[#7A5C44]">Cantidad</p>
|
||||||
|
<p className="text-xl font-heading font-semibold text-homenest-bark">{salesReport.count}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h3 className="font-heading text-xl text-homenest-bark mb-4">Citas por estado</h3>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-[250px] w-full" />
|
||||||
|
) : aptData.length === 0 ? (
|
||||||
|
<EmptyState title="Sin datos" subtitle="No hay citas en el periodo." icon={<BarChart3 size={28} />} />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
|
<BarChart data={aptData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
|
||||||
|
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
||||||
|
<Bar dataKey="value" fill="#57534e" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h3 className="font-heading text-xl text-homenest-bark mb-4">Efectivo por método</h3>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-[250px] w-full" />
|
||||||
|
) : cashData.length === 0 ? (
|
||||||
|
<EmptyState title="Sin datos" subtitle="No hay pagos registrados para el día." icon={<BarChart3 size={28} />} />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={cashData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
|
||||||
|
{cashData.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-4 sm:mt-6">
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-xl text-homenest-bark">Comisiones por médico</h3>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Comisión = % del médico sobre la venta de artículos recetados por él.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[#7A5C44]">
|
||||||
|
Base recetada: <span className="font-semibold text-homenest-bark">${commTotals.base.toLocaleString('es-MX')}</span>
|
||||||
|
{' · '}Comisión total: <span className="font-semibold text-#3E2C1C">${commTotals.commission.toLocaleString('es-MX')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
) : commissions.length === 0 ? (
|
||||||
|
<p className="text-sm text-[#A87B5D]">No hay ventas de artículos recetados en el periodo. Marca las líneas como “Artículo recetado” al crear la venta y asigna el % en Médicos.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[#FEF3C7] text-[#7A5C44] text-xs uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Médico</th>
|
||||||
|
<th className="text-right px-4 py-3">% Comisión</th>
|
||||||
|
<th className="text-right px-4 py-3">Artículos</th>
|
||||||
|
<th className="text-right px-4 py-3">Ventas</th>
|
||||||
|
<th className="text-right px-4 py-3">Base recetada</th>
|
||||||
|
<th className="text-right px-4 py-3">Comisión</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#F5EBD8]">
|
||||||
|
{commissions.map((c) => (
|
||||||
|
<tr key={c.doctor_id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium text-homenest-bark">{c.doctor}</p>
|
||||||
|
<p className="text-xs text-[#A87B5D]">{c.job_title || '—'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-homenest-bark">{c.commission_pct}%</td>
|
||||||
|
<td className="px-4 py-3 text-right text-[#7A5C44]">{c.items}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-[#7A5C44]">{c.sales}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-homenest-bark">${c.base.toLocaleString('es-MX')}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-semibold text-#3E2C1C">${c.commission.toLocaleString('es-MX')}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Reportes;
|
||||||
279
frontend-homenest/src/pages/Servicios.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Star, Edit2, Check, X, Plus, Clock, Tag } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
TextArea,
|
||||||
|
Modal,
|
||||||
|
EmptyState,
|
||||||
|
PageHeader,
|
||||||
|
Skeleton,
|
||||||
|
toast,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type Service } from '../services/odoo';
|
||||||
|
|
||||||
|
const Servicios: FC = () => {
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState<Service | null>(null);
|
||||||
|
const [editPrice, setEditPrice] = useState('');
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
category: '',
|
||||||
|
price: '',
|
||||||
|
duration_min: '',
|
||||||
|
description: '',
|
||||||
|
color: '#78716c',
|
||||||
|
service_group: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await odooApi.getServices();
|
||||||
|
if (res.status === 'success') setServices(res.services);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar servicios');
|
||||||
|
toast.error('Error al cargar servicios');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFavorite = async (s: Service) => {
|
||||||
|
try {
|
||||||
|
await odooApi.updateService(s.id, { is_favorite: !s.is_favorite });
|
||||||
|
toast.success('Favorito actualizado');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al actualizar favorito');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (s: Service) => {
|
||||||
|
setEditing(s);
|
||||||
|
setEditPrice(String(s.price));
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePrice = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const price = parseFloat(editPrice);
|
||||||
|
if (Number.isNaN(price) || price < 0) {
|
||||||
|
toast.error('Precio inválido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await odooApi.updateService(editing.id, { price });
|
||||||
|
toast.success('Precio actualizado');
|
||||||
|
setEditing(null);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al actualizar precio');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
const price = parseFloat(form.price);
|
||||||
|
const duration = parseInt(form.duration_min, 10);
|
||||||
|
if (!form.name || Number.isNaN(price) || price < 0) {
|
||||||
|
toast.error('Nombre y precio válidos son obligatorios');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await odooApi.createService({
|
||||||
|
name: form.name,
|
||||||
|
code: form.code,
|
||||||
|
category: form.category,
|
||||||
|
price,
|
||||||
|
duration_min: Number.isNaN(duration) ? 0 : duration,
|
||||||
|
description: form.description,
|
||||||
|
color: form.color,
|
||||||
|
service_group: form.service_group,
|
||||||
|
});
|
||||||
|
toast.success('Servicio creado');
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
category: '',
|
||||||
|
price: '',
|
||||||
|
duration_min: '',
|
||||||
|
description: '',
|
||||||
|
color: '#78716c',
|
||||||
|
service_group: '',
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al crear servicio');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Servicios" subtitle="Catálogo de tratamientos">
|
||||||
|
<PageHeader title="Servicios" subtitle="Gestiona precios y favoritos">
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Nuevo servicio
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-40 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : services.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Sin servicios"
|
||||||
|
subtitle="No hay servicios registrados."
|
||||||
|
actionLabel="Nuevo servicio"
|
||||||
|
onAction={() => setCreateOpen(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{services.map((s) => (
|
||||||
|
<Card key={s.id} className="hover:shadow-md transition">
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: s.color || '#78716c' }}
|
||||||
|
/>
|
||||||
|
<h4 className="font-medium text-homenest-bark pr-2 truncate">{s.name}</h4>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleFavorite(s)}
|
||||||
|
title={s.is_favorite ? 'Quitar favorito' : 'Marcar favorito'}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={18}
|
||||||
|
className={s.is_favorite ? 'text-amber-500' : 'text-[#E9D5B7]'}
|
||||||
|
fill={s.is_favorite ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-[#7A5C44] mb-4">
|
||||||
|
<span className="inline-flex items-center bg-[#FEF3C7] rounded-md px-2 py-0.5">
|
||||||
|
<Tag size={12} className="mr-1" />
|
||||||
|
{s.category || 'Sin categoría'}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center bg-[#FEF3C7] rounded-md px-2 py-0.5">
|
||||||
|
<Clock size={12} className="mr-1" />
|
||||||
|
{s.duration_min} min
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{editing?.id === s.id ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editPrice}
|
||||||
|
onChange={(e) => setEditPrice(e.target.value)}
|
||||||
|
className="w-28"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" onClick={savePrice} title="Guardar">
|
||||||
|
<Check size={16} className="text-#3E2C1C" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setEditing(null)} title="Cancelar">
|
||||||
|
<X size={16} className="text-[#7A5C44]" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-heading font-semibold text-homenest-bark">${s.price}</p>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => startEdit(s)} title="Editar precio">
|
||||||
|
<Edit2 size={16} className="text-[#7A5C44]" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
title="Nuevo servicio"
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={create} loading={submitting}>Crear servicio</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Nombre *"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Código"
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Categoría"
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Precio *"
|
||||||
|
type="number"
|
||||||
|
value={form.price}
|
||||||
|
onChange={(e) => setForm({ ...form, price: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Duración (min)"
|
||||||
|
type="number"
|
||||||
|
value={form.duration_min}
|
||||||
|
onChange={(e) => setForm({ ...form, duration_min: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Color"
|
||||||
|
type="color"
|
||||||
|
value={form.color}
|
||||||
|
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
label="Descripción"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Servicios;
|
||||||
265
frontend-homenest/src/pages/Usuarios.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Plus, Pencil, KeyRound } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Modal,
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
PageHeader,
|
||||||
|
Skeleton,
|
||||||
|
MobileCard,
|
||||||
|
toast,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
|
||||||
|
import { ROLE_LABELS } from '../lib/auth';
|
||||||
|
|
||||||
|
const roleOptions: { value: FrontendRole; label: string }[] = (
|
||||||
|
['admin', 'recepcion', 'medico', 'lectura'] as FrontendRole[]
|
||||||
|
).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
|
||||||
|
|
||||||
|
const roleBadge = (role: FrontendRole) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'admin':
|
||||||
|
return 'danger';
|
||||||
|
case 'recepcion':
|
||||||
|
return 'info';
|
||||||
|
case 'medico':
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Usuarios: FC = () => {
|
||||||
|
const [users, setUsers] = useState<FrontendUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<FrontendUser | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [form, setForm] = useState({ login: '', name: '', password: '', role: 'recepcion' as FrontendRole, active: true });
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await odooApi.listFrontendUsers();
|
||||||
|
if (res.status === 'success') setUsers(res.users);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cargar usuarios');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({ login: '', name: '', password: '', role: 'recepcion', active: true });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (u: FrontendUser) => {
|
||||||
|
setEditing(u);
|
||||||
|
setForm({ login: u.login, name: u.name, password: '', role: u.role, active: u.active !== false });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error('El nombre es obligatorio');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editing && !form.login.trim()) {
|
||||||
|
toast.error('El usuario es obligatorio');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editing && form.password.length < 8) {
|
||||||
|
toast.error('La contraseña debe tener al menos 8 caracteres');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editing && form.password && form.password.length < 8) {
|
||||||
|
toast.error('La nueva contraseña debe tener al menos 8 caracteres');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
if (editing) {
|
||||||
|
await odooApi.updateFrontendUser(editing.id, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
role: form.role,
|
||||||
|
active: form.active,
|
||||||
|
...(form.password ? { password: form.password } : {}),
|
||||||
|
});
|
||||||
|
toast.success('Usuario actualizado');
|
||||||
|
} else {
|
||||||
|
await odooApi.createFrontendUser({
|
||||||
|
login: form.login.trim(),
|
||||||
|
name: form.name.trim(),
|
||||||
|
password: form.password,
|
||||||
|
role: form.role,
|
||||||
|
});
|
||||||
|
toast.success('Usuario creado');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al guardar usuario');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleActive = async (u: FrontendUser) => {
|
||||||
|
try {
|
||||||
|
await odooApi.updateFrontendUser(u.id, { active: !(u.active !== false) });
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cambiar estado');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Usuarios" subtitle="Cuentas y roles del frontend">
|
||||||
|
<PageHeader title="Usuarios" subtitle="Gestiona quién puede entrar al sistema y su rol">
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Nuevo usuario
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={4} className="h-12 w-full" />
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<EmptyState title="Sin usuarios" subtitle="Crea el primer usuario." actionLabel="Nuevo usuario" onAction={openCreate} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Usuario</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Nombre</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Rol</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden md:table-cell">Último acceso</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Estado</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3 text-sm font-mono text-homenest-bark">{u.login}</td>
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">{u.name}</td>
|
||||||
|
<td className="p-3"><Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge></td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden md:table-cell">{u.last_login || '—'}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(u)} title="Editar">
|
||||||
|
<Pencil size={16} className="text-[#7A5C44]" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)} title={u.active !== false ? 'Desactivar' : 'Activar'}>
|
||||||
|
<KeyRound size={16} className={u.active !== false ? 'text-amber-600' : 'text-#3E2C1C'} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{users.map((u) => (
|
||||||
|
<MobileCard
|
||||||
|
key={u.id}
|
||||||
|
title={u.name}
|
||||||
|
subtitle={u.login}
|
||||||
|
rows={[
|
||||||
|
{ label: 'Rol', value: <Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge> },
|
||||||
|
{ label: 'Estado', value: <Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge> },
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={16} className="text-[#7A5C44]" /></Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)}><KeyRound size={16} className="text-amber-600" /></Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
title={editing ? 'Editar usuario' : 'Nuevo usuario'}
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Usuario (login)"
|
||||||
|
value={form.login}
|
||||||
|
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||||
|
disabled={!!editing}
|
||||||
|
placeholder="ej. recepcion1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Nombre"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="Nombre completo"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Rol"
|
||||||
|
options={roleOptions}
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => setForm({ ...form, role: e.target.value as FrontendRole })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={editing ? 'Nueva contraseña (dejar vacío para no cambiar)' : 'Contraseña (mín. 8)'}
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
{editing && (
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-homenest-bark cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.active}
|
||||||
|
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||||
|
className="rounded border-[#E9D5B7]"
|
||||||
|
/>
|
||||||
|
Usuario activo
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Usuarios;
|
||||||
631
frontend-homenest/src/pages/Ventas.tsx
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
Input,
|
||||||
|
TextArea,
|
||||||
|
Modal,
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
PageHeader,
|
||||||
|
Skeleton,
|
||||||
|
MobileCard,
|
||||||
|
toast,
|
||||||
|
badgeForSaleState,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type Sale, type SaleLine, type Service, type Patient, type Doctor } from '../services/odoo';
|
||||||
|
import { downloadCsv } from '../lib/utils';
|
||||||
|
|
||||||
|
interface LineForm {
|
||||||
|
service_id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
price_unit: string;
|
||||||
|
is_prescribed: boolean;
|
||||||
|
prescribed_by_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyLine = (): LineForm => ({
|
||||||
|
service_id: '', description: '', quantity: '1', price_unit: '',
|
||||||
|
is_prescribed: false, prescribed_by_id: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
|
||||||
|
|
||||||
|
const Ventas: FC = () => {
|
||||||
|
const [sales, setSales] = useState<Sale[]>([]);
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [payOpen, setPayOpen] = useState<Sale | null>(null);
|
||||||
|
const [patientId, setPatientId] = useState('');
|
||||||
|
const [lines, setLines] = useState<LineForm[]>([
|
||||||
|
emptyLine(),
|
||||||
|
]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [paying, setPaying] = useState(false);
|
||||||
|
const [payMethod, setPayMethod] = useState('Efectivo');
|
||||||
|
const [payAmount, setPayAmount] = useState('');
|
||||||
|
const [refundOpen, setRefundOpen] = useState<Sale | null>(null);
|
||||||
|
const [refunding, setRefunding] = useState(false);
|
||||||
|
const [refundAmount, setRefundAmount] = useState('');
|
||||||
|
const [refundReason, setRefundReason] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [patientSearch, setPatientSearch] = useState('');
|
||||||
|
|
||||||
|
const loadSales = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await odooApi.getSales({ search, page, page_size: pageSize });
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setSales(res.sales);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
setTotalPages(res.total_pages ?? 0);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar ventas');
|
||||||
|
toast.error('Error al cargar ventas');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [search, page, pageSize]);
|
||||||
|
|
||||||
|
const loadRefs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const servicesRes = await odooApi.getServices();
|
||||||
|
if (servicesRes.status === 'success') setServices(servicesRes.services);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const doctorsRes = await odooApi.getDoctors();
|
||||||
|
if (doctorsRes.status === 'success') setDoctors(doctorsRes.doctors);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRefs();
|
||||||
|
}, [loadRefs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSales();
|
||||||
|
}, [loadSales]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Cargar pacientes para el selector (búsqueda server-side)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createOpen) return;
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getPatients({ search: patientSearch, page_size: 50 });
|
||||||
|
if (res.status === 'success') setPatients(res.patients);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [createOpen, patientSearch]);
|
||||||
|
|
||||||
|
const addLine = () =>
|
||||||
|
setLines([...lines, emptyLine()]);
|
||||||
|
|
||||||
|
const removeLine = (index: number) => setLines(lines.filter((_, i) => i !== index));
|
||||||
|
|
||||||
|
const updateLine = (index: number, field: keyof LineForm, value: string | boolean) => {
|
||||||
|
const next = [...lines];
|
||||||
|
next[index] = { ...next[index], [field]: value };
|
||||||
|
setLines(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!patientId) {
|
||||||
|
toast.error('Selecciona un paciente');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsedLines: Omit<SaleLine, 'subtotal'>[] = lines
|
||||||
|
.filter((l) => l.service_id)
|
||||||
|
.map((l): Omit<SaleLine, 'subtotal'> | null => {
|
||||||
|
const qty = parseFloat(l.quantity);
|
||||||
|
const price = parseFloat(l.price_unit);
|
||||||
|
if (Number.isNaN(qty) || qty <= 0 || Number.isNaN(price) || price < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
service_id: parseInt(l.service_id, 10),
|
||||||
|
description: l.description,
|
||||||
|
quantity: qty,
|
||||||
|
price_unit: price,
|
||||||
|
is_prescribed: l.is_prescribed,
|
||||||
|
prescribed_by_id: l.is_prescribed && l.prescribed_by_id ? parseInt(l.prescribed_by_id, 10) : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((l): l is Omit<SaleLine, 'subtotal'> => l !== null);
|
||||||
|
|
||||||
|
if (parsedLines.some((l) => l === null)) {
|
||||||
|
toast.error('Revisa cantidades y precios de las líneas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsedLines.length === 0) {
|
||||||
|
toast.error('Agrega al menos una línea');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await odooApi.createSale({
|
||||||
|
patient_id: parseInt(patientId, 10),
|
||||||
|
lines: parsedLines,
|
||||||
|
});
|
||||||
|
toast.success('Venta creada');
|
||||||
|
setCreateOpen(false);
|
||||||
|
setPatientId('');
|
||||||
|
setLines([emptyLine()]);
|
||||||
|
await loadSales();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al crear venta');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPay = (sale: Sale) => {
|
||||||
|
setPayOpen(sale);
|
||||||
|
setPayMethod('Efectivo');
|
||||||
|
setPayAmount(String(sale.amount_due));
|
||||||
|
};
|
||||||
|
|
||||||
|
const pay = async () => {
|
||||||
|
if (!payOpen) return;
|
||||||
|
const amount = payAmount ? parseFloat(payAmount) : undefined;
|
||||||
|
if (amount !== undefined && (Number.isNaN(amount) || amount <= 0)) {
|
||||||
|
toast.error('Monto inválido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setPaying(true);
|
||||||
|
await odooApi.paySale(payOpen.id, { payment_method: payMethod, amount });
|
||||||
|
toast.success('Pago registrado');
|
||||||
|
setPayOpen(null);
|
||||||
|
await loadSales();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al pagar venta');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setPaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRefund = (sale: Sale) => {
|
||||||
|
setRefundOpen(sale);
|
||||||
|
setRefundAmount(String(sale.amount_paid));
|
||||||
|
setRefundReason('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRefund = async () => {
|
||||||
|
if (!refundOpen) return;
|
||||||
|
const amount = refundAmount ? parseFloat(refundAmount) : undefined;
|
||||||
|
if (amount !== undefined && (Number.isNaN(amount) || amount <= 0)) {
|
||||||
|
toast.error('Monto inválido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (amount !== undefined && amount > refundOpen.amount_paid) {
|
||||||
|
toast.error(`El monto no puede exceder lo pagado ($${refundOpen.amount_paid})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setRefunding(true);
|
||||||
|
const res = await odooApi.refundSale(refundOpen.id, { amount, reason: refundReason || undefined });
|
||||||
|
const pts = res.result?.points_reversed ?? 0;
|
||||||
|
toast.success(`Devolución registrada${pts > 0 ? ` · ${pts} pts revertidos` : ''}`);
|
||||||
|
setRefundOpen(null);
|
||||||
|
await loadSales();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al registrar devolución');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setRefunding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportSales = () => {
|
||||||
|
const rows: (string | number)[][] = [
|
||||||
|
['Referencia', 'Paciente', 'Fecha', 'Subtotal', 'Descuento', 'Impuesto', 'Total', 'Pagado', 'Por cobrar', 'Estado', 'Devuelta', 'Monto devuelto'],
|
||||||
|
];
|
||||||
|
sales.forEach((s) =>
|
||||||
|
rows.push([
|
||||||
|
s.name, s.patient, s.date, s.subtotal, s.discount, s.tax, s.total,
|
||||||
|
s.amount_paid, s.amount_due, s.state, s.refunded ? 'Sí' : 'No', s.refund_amount || 0,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
rows.push(['', '', 'Totales', '', '', '', sales.reduce((a, s) => a + s.total, 0), sales.reduce((a, s) => a + s.amount_paid, 0), sales.reduce((a, s) => a + s.amount_due, 0), '', '', sales.reduce((a, s) => a + (s.refund_amount || 0), 0)]);
|
||||||
|
downloadCsv(`ventas-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
|
||||||
|
toast.success(`CSV descargado (${sales.length} ventas de la vista actual)`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const patientOptions = [
|
||||||
|
{ value: '', label: 'Seleccionar paciente...' },
|
||||||
|
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const serviceOptions = [
|
||||||
|
{ value: '', label: 'Servicio...' },
|
||||||
|
...services.map((s) => ({ value: String(s.id), label: `${s.name} — $${s.price}` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const doctorOptions = [
|
||||||
|
{ value: '', label: 'Médico que recetó...' },
|
||||||
|
...doctors.map((d) => ({ value: String(d.id), label: `${d.name} (${d.commission_pct ?? 0}%)` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const payMethodOptions = paymentMethods.map((m) => ({ value: m, label: m }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Ventas" subtitle="Órdenes de venta">
|
||||||
|
<PageHeader title="Ventas" subtitle="Crea ventas y registra pagos">
|
||||||
|
<Button variant="outline" onClick={exportSales} disabled={sales.length === 0}>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Exportar CSV
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Nueva venta
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||||
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#A87B5D]" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por referencia o paciente..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={6} className="h-12 w-full" />
|
||||||
|
) : sales.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Sin ventas"
|
||||||
|
subtitle="No hay ventas registradas."
|
||||||
|
actionLabel="Nueva venta"
|
||||||
|
onAction={() => setCreateOpen(true)}
|
||||||
|
icon={<ShoppingCart size={28} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Referencia</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Paciente</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Total</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden md:table-cell">Pagado</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Estado</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{sales.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44]">{s.name}</td>
|
||||||
|
<td className="p-3 text-sm font-medium text-homenest-bark">{s.patient}</td>
|
||||||
|
<td className="p-3 text-sm font-semibold text-homenest-bark">${s.total}</td>
|
||||||
|
<td className="p-3 text-sm text-[#7A5C44] hidden md:table-cell">${s.amount_paid}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
|
||||||
|
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{s.amount_due > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openPay(s)} title="Pagar">
|
||||||
|
<CreditCard size={16} className="text-#3E2C1C mr-1.5" />
|
||||||
|
Pagar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{s.amount_paid > 0 && s.state !== 'cancelled' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
||||||
|
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
||||||
|
Devolución
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{sales.map((s) => (
|
||||||
|
<MobileCard
|
||||||
|
key={s.id}
|
||||||
|
title={s.patient}
|
||||||
|
subtitle={s.name}
|
||||||
|
rows={[
|
||||||
|
{ label: 'Total', value: `$${s.total}` },
|
||||||
|
{ label: 'Pagado', value: `$${s.amount_paid}` },
|
||||||
|
{
|
||||||
|
label: 'Estado',
|
||||||
|
value: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
|
||||||
|
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{s.amount_due > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
||||||
|
<CreditCard size={16} className="text-#3E2C1C" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{s.amount_paid > 0 && s.state !== 'cancelled' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
||||||
|
<Undo2 size={16} className="text-amber-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paginación */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-[#F5EBD8]">
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
{total} ventas · página {page} de {totalPages || 1}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
|
||||||
|
className="border border-[#E9D5B7] rounded-lg px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={200}>200</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
title="Nueva venta"
|
||||||
|
maxWidth="2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={create} loading={submitting}>Crear venta</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Buscar paciente"
|
||||||
|
placeholder="Escribe nombre o teléfono..."
|
||||||
|
value={patientSearch}
|
||||||
|
onChange={(e) => setPatientSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Paciente *"
|
||||||
|
options={patientOptions}
|
||||||
|
value={patientId}
|
||||||
|
onChange={(e) => setPatientId(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-xs font-medium text-[#7A5C44]">Líneas</label>
|
||||||
|
<Button variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus size={14} className="mr-1" />
|
||||||
|
Agregar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="grid grid-cols-12 gap-2 items-start">
|
||||||
|
<div className="col-span-12 sm:col-span-4">
|
||||||
|
<Select
|
||||||
|
options={serviceOptions}
|
||||||
|
value={line.service_id}
|
||||||
|
onChange={(e) => {
|
||||||
|
const sid = e.target.value;
|
||||||
|
const svc = services.find((s) => String(s.id) === sid);
|
||||||
|
updateLine(i, 'service_id', sid);
|
||||||
|
if (svc) {
|
||||||
|
updateLine(i, 'price_unit', String(svc.price));
|
||||||
|
updateLine(i, 'description', svc.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="Descripción"
|
||||||
|
value={line.description}
|
||||||
|
onChange={(e) => updateLine(i, 'description', e.target.value)}
|
||||||
|
className="col-span-12 sm:col-span-4"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Cant"
|
||||||
|
value={line.quantity}
|
||||||
|
onChange={(e) => updateLine(i, 'quantity', e.target.value)}
|
||||||
|
className="col-span-4 sm:col-span-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Precio"
|
||||||
|
value={line.price_unit}
|
||||||
|
onChange={(e) => updateLine(i, 'price_unit', e.target.value)}
|
||||||
|
className="col-span-6 sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeLine(i)}
|
||||||
|
className="col-span-2 sm:col-span-1 h-[42px]"
|
||||||
|
title="Eliminar línea"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} className="text-rose-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pl-1">
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-[#7A5C44] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={line.is_prescribed}
|
||||||
|
onChange={(e) => updateLine(i, 'is_prescribed', e.target.checked)}
|
||||||
|
className="rounded border-[#E9D5B7]"
|
||||||
|
/>
|
||||||
|
Artículo recetado
|
||||||
|
</label>
|
||||||
|
{line.is_prescribed && (
|
||||||
|
<Select
|
||||||
|
options={doctorOptions}
|
||||||
|
value={line.prescribed_by_id}
|
||||||
|
onChange={(e) => updateLine(i, 'prescribed_by_id', e.target.value)}
|
||||||
|
className="sm:max-w-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!payOpen}
|
||||||
|
onClose={() => setPayOpen(null)}
|
||||||
|
title={`Pagar ${payOpen?.name || ''}`}
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setPayOpen(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={pay} loading={paying}>Registrar pago</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{payOpen && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
Paciente: <span className="font-medium text-homenest-bark">{payOpen.patient}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
Total: <span className="font-medium text-homenest-bark">${payOpen.total}</span>
|
||||||
|
{' · '}
|
||||||
|
Por pagar: <span className="font-medium text-homenest-bark">${payOpen.amount_due}</span>
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
label="Método de pago"
|
||||||
|
options={payMethodOptions}
|
||||||
|
value={payMethod}
|
||||||
|
onChange={(e) => setPayMethod(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Monto"
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step={0.01}
|
||||||
|
value={payAmount}
|
||||||
|
onChange={(e) => setPayAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!refundOpen}
|
||||||
|
onClose={() => setRefundOpen(null)}
|
||||||
|
title={`Devolución ${refundOpen?.name || ''}`}
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setRefundOpen(null)}>Cancelar</Button>
|
||||||
|
<Button variant="danger" onClick={submitRefund} loading={refunding}>Confirmar devolución</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{refundOpen && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
Paciente: <span className="font-medium text-homenest-bark">{refundOpen.patient}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#7A5C44]">
|
||||||
|
Total: <span className="font-medium text-homenest-bark">${refundOpen.total}</span>
|
||||||
|
{' · '}
|
||||||
|
Pagado: <span className="font-medium text-homenest-bark">${refundOpen.amount_paid}</span>
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg bg-amber-50 border border-#FEF3C7 px-3 py-2 text-xs text-amber-800">
|
||||||
|
La devolución reduce el monto pagado y revierte los puntos del monedero en proporción ($10 = 1 pt). Deja el monto vacío o igual a lo pagado para devolución total.
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Monto a devolver"
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
max={refundOpen.amount_paid}
|
||||||
|
step={0.01}
|
||||||
|
value={refundAmount}
|
||||||
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
label="Motivo"
|
||||||
|
placeholder="Motivo de la devolución..."
|
||||||
|
value={refundReason}
|
||||||
|
onChange={(e) => setRefundReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Ventas;
|
||||||
501
frontend-homenest/src/pages/WacrmLeads.tsx
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Target,
|
||||||
|
Search,
|
||||||
|
TrendingUp,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Circle,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
MoreHorizontal,
|
||||||
|
User,
|
||||||
|
ArrowRightCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, Button, Input, Select, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
|
||||||
|
import { odooApi, type WacrmLead, type WacrmPipeline } from '../services/odoo';
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: '', label: 'Todos los estados' },
|
||||||
|
{ value: 'open', label: 'Abierto' },
|
||||||
|
{ value: 'won', label: 'Ganado' },
|
||||||
|
{ value: 'lost', label: 'Perdido' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'won':
|
||||||
|
return 'success';
|
||||||
|
case 'lost':
|
||||||
|
return 'danger';
|
||||||
|
default:
|
||||||
|
return 'primary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'won':
|
||||||
|
return <CheckCircle size={14} className="mr-1" />;
|
||||||
|
case 'lost':
|
||||||
|
return <XCircle size={14} className="mr-1" />;
|
||||||
|
default:
|
||||||
|
return <Circle size={14} className="mr-1" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currencySymbol = (currency: string) => {
|
||||||
|
if (currency === 'MXN') return '$';
|
||||||
|
if (currency === 'USD') return 'US$';
|
||||||
|
return currency;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WacrmLeads: FC = () => {
|
||||||
|
const [leads, setLeads] = useState<WacrmLead[]>([]);
|
||||||
|
const [pipelines, setPipelines] = useState<WacrmPipeline[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [pipelineId, setPipelineId] = useState('');
|
||||||
|
const [view, setView] = useState<'list' | 'kanban'>('kanban');
|
||||||
|
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||||
|
const [selectedLead, setSelectedLead] = useState<WacrmLead | null>(null);
|
||||||
|
const [stageModalOpen, setStageModalOpen] = useState(false);
|
||||||
|
const [movingStage, setMovingStage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [leadsRes, pipelinesRes] = await Promise.all([
|
||||||
|
odooApi.getWacrmLeads(search || undefined, status || undefined, pipelineId || undefined),
|
||||||
|
odooApi.getWacrmPipelines(),
|
||||||
|
]);
|
||||||
|
if (leadsRes.status === 'success') setLeads(leadsRes.leads);
|
||||||
|
if (pipelinesRes.status === 'success') setPipelines(pipelinesRes.pipelines);
|
||||||
|
setLastSync(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cargar leads');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [search, status, pipelineId]);
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
try {
|
||||||
|
setSyncing(true);
|
||||||
|
const res = await odooApi.syncWacrm();
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast.success(`Sincronizado: ${res.result.deals || 0} deals`);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al sincronizar con WACRM');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStatus = async (lead: WacrmLead, newStatus: 'open' | 'won' | 'lost') => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.updateWacrmLeadStatus(lead.id, { status: newStatus });
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast.success(`Lead marcado como ${newStatus}`);
|
||||||
|
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al actualizar lead');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeStage = async (lead: WacrmLead, stageExternalId: string) => {
|
||||||
|
try {
|
||||||
|
setMovingStage(stageExternalId);
|
||||||
|
const res = await odooApi.updateWacrmLeadStatus(lead.id, { stage_id: stageExternalId });
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast.success('Etapa actualizada');
|
||||||
|
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
|
||||||
|
setStageModalOpen(false);
|
||||||
|
setSelectedLead(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cambiar etapa');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setMovingStage(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// Auto-sync cada 15 segundos
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
load();
|
||||||
|
}, 15000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const pipelineOptions = useMemo(
|
||||||
|
() => [{ value: '', label: 'Todos los pipelines' }, ...pipelines.map((p) => ({ value: p.external_id, label: p.name }))],
|
||||||
|
[pipelines]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activePipeline = useMemo(
|
||||||
|
() => pipelines.find((p) => p.external_id === pipelineId) || pipelines[0],
|
||||||
|
[pipelines, pipelineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredLeads = useMemo(() => {
|
||||||
|
if (!activePipeline) return leads;
|
||||||
|
return leads.filter((l) => l.pipeline_id === activePipeline.id);
|
||||||
|
}, [leads, activePipeline]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const open = leads.filter((l) => l.status === 'open');
|
||||||
|
const won = leads.filter((l) => l.status === 'won');
|
||||||
|
const lost = leads.filter((l) => l.status === 'lost');
|
||||||
|
return {
|
||||||
|
total: leads.length,
|
||||||
|
openValue: open.reduce((sum, l) => sum + (l.value || 0), 0),
|
||||||
|
wonValue: won.reduce((sum, l) => sum + (l.value || 0), 0),
|
||||||
|
lostValue: lost.reduce((sum, l) => sum + (l.value || 0), 0),
|
||||||
|
openCount: open.length,
|
||||||
|
wonCount: won.length,
|
||||||
|
lostCount: lost.length,
|
||||||
|
};
|
||||||
|
}, [leads]);
|
||||||
|
|
||||||
|
const renderList = () => (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#FEF3C7]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Lead</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden sm:table-cell">Contacto</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase hidden md:table-cell">Pipeline / Etapa</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Valor</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Estado</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-[#7A5C44] uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{filteredLeads.map((l) => (
|
||||||
|
<tr key={l.external_id} className="hover:bg-[#FEF3C7]">
|
||||||
|
<td className="p-3">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark">{l.title}</p>
|
||||||
|
<p className="text-xs text-[#7A5C44] line-clamp-1">{l.notes || 'Sin notas'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 hidden sm:table-cell">
|
||||||
|
<p className="text-sm text-homenest-bark">{l.contact_name || '—'}</p>
|
||||||
|
<p className="text-xs text-[#7A5C44]">{l.contact_phone || '—'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 hidden md:table-cell">
|
||||||
|
<p className="text-xs text-[#7A5C44]">{l.pipeline || '—'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLead(l);
|
||||||
|
setStageModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium hover:opacity-80 transition"
|
||||||
|
style={{ backgroundColor: `${l.stage_color}20`, color: l.stage_color }}
|
||||||
|
>
|
||||||
|
{l.stage || '—'} <ArrowRightCircle size={12} className="ml-1" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark">
|
||||||
|
{currencySymbol(l.currency)}{l.value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant={statusBadge(l.status)}>
|
||||||
|
{statusIcon(l.status)}
|
||||||
|
{l.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{l.status !== 'won' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(l, 'won')}
|
||||||
|
className="p-1.5 text-#3E2C1C hover:bg-#E8F5E3 rounded"
|
||||||
|
title="Marcar ganado"
|
||||||
|
>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{l.status !== 'lost' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(l, 'lost')}
|
||||||
|
className="p-1.5 text-rose-600 hover:bg-rose-50 rounded"
|
||||||
|
title="Marcar perdido"
|
||||||
|
>
|
||||||
|
<XCircle size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{l.status !== 'open' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(l, 'open')}
|
||||||
|
className="p-1.5 text-[#7A5C44] hover:bg-[#FEF3C7] rounded"
|
||||||
|
title="Reabrir"
|
||||||
|
>
|
||||||
|
<Circle size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderKanban = () => {
|
||||||
|
if (!activePipeline) return <EmptyState title="Sin pipeline" subtitle="Selecciona un pipeline para ver el Kanban." />;
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto pb-2">
|
||||||
|
<div className="flex gap-4 min-w-max">
|
||||||
|
{activePipeline.stages.map((stage) => {
|
||||||
|
const stageLeads = filteredLeads.filter((l) => l.stage_id === stage.id);
|
||||||
|
const stageValue = stageLeads.reduce((sum, l) => sum + (l.value || 0), 0);
|
||||||
|
return (
|
||||||
|
<div key={stage.external_id} className="w-72 flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: stage.color }} />
|
||||||
|
<h3 className="text-sm font-semibold text-homenest-bark">{stage.name}</h3>
|
||||||
|
<span className="text-xs text-[#7A5C44] bg-[#FEF3C7] px-1.5 py-0.5 rounded-full">{stageLeads.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stageLeads.map((l) => (
|
||||||
|
<div key={l.external_id} style={{ borderLeftColor: stage.color }}>
|
||||||
|
<Card className="border-l-4">
|
||||||
|
<Card.Body className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark line-clamp-2">{l.title}</p>
|
||||||
|
<Badge variant={statusBadge(l.status)} className="text-[10px] px-1.5 py-0">
|
||||||
|
{statusIcon(l.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-[#7A5C44] mb-2">
|
||||||
|
<User size={12} />
|
||||||
|
<span className="truncate">{l.contact_name || l.contact_phone || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-homenest-bark">
|
||||||
|
{currencySymbol(l.currency)}{l.value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLead(l);
|
||||||
|
setStageModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="p-1 text-[#A87B5D] hover:text-homenest-bark hover:bg-[#FEF3C7] rounded"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 mt-2">
|
||||||
|
{l.status !== 'won' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(l, 'won')}
|
||||||
|
className="flex-1 py-1 text-[10px] font-medium text-#3E2C1C bg-#E8F5E3 hover:bg-#D9F99D rounded"
|
||||||
|
>
|
||||||
|
Ganar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{l.status !== 'lost' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(l, 'lost')}
|
||||||
|
className="flex-1 py-1 text-[10px] font-medium text-rose-700 bg-rose-50 hover:bg-rose-100 rounded"
|
||||||
|
>
|
||||||
|
Perder
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stageLeads.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-xs text-[#A87B5D] border border-dashed border-[#E9D5B7] rounded-lg">
|
||||||
|
Sin leads en esta etapa
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{stageValue > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-[#7A5C44] text-right">
|
||||||
|
Total: {currencySymbol(activePipeline.stages[0]?.color ? 'MXN' : 'MXN')}{stageValue.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="WACRM — Leads" subtitle="Deals y oportunidades del CRM">
|
||||||
|
{/* Métricas */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4 sm:mb-6">
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-[#FEF3C7] rounded-lg">
|
||||||
|
<Target size={20} className="text-homenest-bark" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Total leads</p>
|
||||||
|
<p className="text-lg font-semibold text-homenest-bark">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-50 rounded-lg">
|
||||||
|
<TrendingUp size={20} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Valor abierto</p>
|
||||||
|
<p className="text-lg font-semibold text-homenest-bark">${stats.openValue.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-#E8F5E3 rounded-lg">
|
||||||
|
<CheckCircle size={20} className="text-#3E2C1C" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Ganados</p>
|
||||||
|
<p className="text-lg font-semibold text-homenest-bark">{stats.wonCount} <span className="text-xs font-normal text-[#7A5C44]">(${stats.wonValue.toLocaleString()})</span></p>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-rose-50 rounded-lg">
|
||||||
|
<XCircle size={20} className="text-rose-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[#7A5C44]">Perdidos</p>
|
||||||
|
<p className="text-lg font-semibold text-homenest-bark">{stats.lostCount}</p>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
{/* Filtros */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-3 mb-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#A87B5D]" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar lead o contacto..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
options={statusOptions}
|
||||||
|
className="w-full lg:w-44"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={pipelineId}
|
||||||
|
onChange={(e) => setPipelineId(e.target.value)}
|
||||||
|
options={pipelineOptions}
|
||||||
|
className="w-full lg:w-52"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="inline-flex rounded-lg border border-[#E9D5B7] overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('kanban')}
|
||||||
|
className={`px-3 py-2 flex items-center gap-1.5 text-sm ${view === 'kanban' ? 'bg-[#5C4634] text-white' : 'bg-homenest-cream-light text-[#7A5C44] hover:bg-[#FEF3C7]'}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={16} /> Kanban
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('list')}
|
||||||
|
className={`px-3 py-2 flex items-center gap-1.5 text-sm ${view === 'list' ? 'bg-[#5C4634] text-white' : 'bg-homenest-cream-light text-[#7A5C44] hover:bg-[#FEF3C7]'}`}
|
||||||
|
>
|
||||||
|
<List size={16} /> Lista
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={sync} loading={syncing}>
|
||||||
|
<RefreshCw size={16} className="mr-2" />
|
||||||
|
Sincronizar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastSync && (
|
||||||
|
<p className="text-xs text-[#A87B5D] mb-3">
|
||||||
|
Última sincronización: {lastSync.toLocaleTimeString()} · se actualiza automáticamente cada 15s
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && leads.length === 0 ? (
|
||||||
|
<Skeleton count={6} className="h-14 w-full" />
|
||||||
|
) : filteredLeads.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Sin leads"
|
||||||
|
subtitle="No hay deals sincronizados. Presiona Sincronizar para traer datos de WACRM."
|
||||||
|
icon={<Target size={28} />}
|
||||||
|
actionLabel="Sincronizar"
|
||||||
|
onAction={sync}
|
||||||
|
/>
|
||||||
|
) : view === 'list' ? (
|
||||||
|
renderList()
|
||||||
|
) : (
|
||||||
|
renderKanban()
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Modal cambiar etapa */}
|
||||||
|
<Modal isOpen={stageModalOpen} onClose={() => setStageModalOpen(false)} title="Mover a etapa">
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{activePipeline?.stages.map((stage) => {
|
||||||
|
const isCurrent = selectedLead?.stage_id === stage.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={stage.external_id}
|
||||||
|
disabled={isCurrent || !!movingStage}
|
||||||
|
onClick={() => selectedLead && changeStage(selectedLead, stage.external_id)}
|
||||||
|
className={`w-full flex items-center justify-between p-3 rounded-lg border text-left transition ${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-[#FEF3C7] border-[#E9D5B7] text-[#A87B5D] cursor-not-allowed'
|
||||||
|
: 'bg-homenest-cream-light border-[#E9D5B7] hover:border-[#E9D5B7] hover:bg-[#FEF3C7]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: stage.color }} />
|
||||||
|
<span className="text-sm font-medium text-homenest-bark">{stage.name}</span>
|
||||||
|
</div>
|
||||||
|
{isCurrent && <span className="text-xs text-[#A87B5D]">Actual</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WacrmLeads;
|
||||||
452
frontend-homenest/src/pages/WacrmMessages.tsx
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, Button, Input, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
|
||||||
|
import { odooApi, type WacrmConversation, type WacrmMessage } from '../services/odoo';
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WacrmMessages: FC = () => {
|
||||||
|
const [conversations, setConversations] = useState<WacrmConversation[]>([]);
|
||||||
|
const [messages, setMessages] = useState<WacrmMessage[]>([]);
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selected, setSelected] = useState<WacrmConversation | null>(null);
|
||||||
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
|
const [assigning, setAssigning] = useState(false);
|
||||||
|
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [sendingReply, setSendingReply] = useState(false);
|
||||||
|
const [currentAgentId, setCurrentAgentId] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return localStorage.getItem('skeen_current_agent_id') || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refs para evitar loops y dependency churn
|
||||||
|
const selectedRef = useRef<WacrmConversation | null>(null);
|
||||||
|
const searchRef = useRef(search);
|
||||||
|
const userSelectedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedRef.current = selected;
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
searchRef.current = search;
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentAgentId && typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('skeen_current_agent_id', currentAgentId);
|
||||||
|
}
|
||||||
|
}, [currentAgentId]);
|
||||||
|
|
||||||
|
const loadConversations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getWacrmConversations(searchRef.current);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setConversations(res.conversations);
|
||||||
|
const currentSelected = selectedRef.current;
|
||||||
|
if (currentSelected) {
|
||||||
|
const updated = res.conversations.find((c) => c.external_id === currentSelected.external_id);
|
||||||
|
if (updated && updated.external_id !== currentSelected.external_id) {
|
||||||
|
// Solo actualizar si realmente cambió la referencia (datos nuevos)
|
||||||
|
setSelected(updated);
|
||||||
|
}
|
||||||
|
} else if (!userSelectedRef.current && res.conversations.length > 0) {
|
||||||
|
setSelected(res.conversations[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cargar conversaciones');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMessages = useCallback(async (conversationId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getWacrmMessages(undefined, conversationId);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setMessages(res.messages.reverse());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al cargar mensajes');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMembers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getWacrmMembers();
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setMembers(res.members);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error cargando agentes:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
try {
|
||||||
|
setSyncing(true);
|
||||||
|
const res = await odooApi.syncWacrm();
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast.success(`Sincronizado: ${JSON.stringify(res.result)}`);
|
||||||
|
await loadConversations();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al sincronizar con WACRM');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Carga inicial
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const init = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await Promise.all([loadConversations(), loadMembers()]);
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cuando cambia la búsqueda, recargar lista pero no forzar selección
|
||||||
|
useEffect(() => {
|
||||||
|
loadConversations();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Cargar mensajes cuando cambia la conversación seleccionada
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected) {
|
||||||
|
loadMessages(selected.external_id);
|
||||||
|
}
|
||||||
|
}, [selected, loadMessages]);
|
||||||
|
|
||||||
|
// Auto-sync cada 15 segundos
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadConversations();
|
||||||
|
const currentSelected = selectedRef.current;
|
||||||
|
if (currentSelected) {
|
||||||
|
loadMessages(currentSelected.external_id);
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadConversations, loadMessages]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback((c: WacrmConversation) => {
|
||||||
|
userSelectedRef.current = true;
|
||||||
|
setSelected(c);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSendReply = async () => {
|
||||||
|
if (!selected || !replyText.trim()) return;
|
||||||
|
if (!currentAgentId) {
|
||||||
|
toast.error('Selecciona tu identidad de agente antes de responder');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSendingReply(true);
|
||||||
|
await odooApi.sendWacrmMessage({
|
||||||
|
conversation_id: selected.external_id,
|
||||||
|
text: replyText.trim(),
|
||||||
|
assigned_agent_id: currentAgentId,
|
||||||
|
});
|
||||||
|
toast.success('Mensaje enviado');
|
||||||
|
setReplyText('');
|
||||||
|
await loadMessages(selected.external_id);
|
||||||
|
await loadConversations();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al enviar mensaje');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSendingReply(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssign = async (memberId: string | null) => {
|
||||||
|
if (!selected) return;
|
||||||
|
try {
|
||||||
|
setAssigning(true);
|
||||||
|
await odooApi.assignWacrmConversation(selected.external_id, memberId);
|
||||||
|
toast.success(memberId ? 'Conversación asignada' : 'Conversación desasignada');
|
||||||
|
await loadConversations();
|
||||||
|
setAssignOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al asignar conversación');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setAssigning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const senderIcon = (type: string) => {
|
||||||
|
if (type === 'bot') return <Bot size={14} className="text-#3E2C1C" />;
|
||||||
|
if (type === 'agent') return <Headphones size={14} className="text-blue-600" />;
|
||||||
|
return <User size={14} className="text-[#7A5C44]" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const senderLabel = (type: string) => {
|
||||||
|
if (type === 'bot') return 'Sofía (Bot)';
|
||||||
|
if (type === 'agent') return 'Agente';
|
||||||
|
return 'Cliente';
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignedMember = members.find((m) => m.id === selected?.assigned_agent_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="WACRM — Mensajes" subtitle="Conversaciones de WhatsApp sincronizadas">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4 sm:mb-6">
|
||||||
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#A87B5D]" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar conversación..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={currentAgentId}
|
||||||
|
onChange={(e) => setCurrentAgentId(e.target.value)}
|
||||||
|
className="appearance-none bg-homenest-cream-light border border-[#E9D5B7] text-homenest-bark text-sm rounded-lg pl-3 pr-8 py-2 focus:outline-none focus:ring-2 focus:ring-[#3E2C1C]"
|
||||||
|
>
|
||||||
|
<option value="">Responder como...</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#A87B5D] pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
<Button onClick={sync} loading={syncing}>
|
||||||
|
<RefreshCw size={16} className="mr-2" />
|
||||||
|
Sincronizar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton count={6} className="h-20 w-full" />
|
||||||
|
) : conversations.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Sin conversaciones"
|
||||||
|
subtitle="No hay conversaciones sincronizadas. Presiona Sincronizar para traer datos de WACRM."
|
||||||
|
icon={<MessageSquare size={28} />}
|
||||||
|
actionLabel="Sincronizar"
|
||||||
|
onAction={sync}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6 h-[calc(100vh-220px)] min-h-[400px]">
|
||||||
|
{/* Lista de conversaciones */}
|
||||||
|
<Card className="lg:col-span-1 overflow-hidden flex flex-col">
|
||||||
|
<Card.Body className="p-0 overflow-y-auto flex-1">
|
||||||
|
<div className="divide-y">
|
||||||
|
{conversations.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.external_id}
|
||||||
|
onClick={() => handleSelect(c)}
|
||||||
|
className={`w-full text-left p-3 sm:p-4 transition hover:bg-[#FEF3C7] ${
|
||||||
|
selected?.external_id === c.external_id ? 'bg-[#FEF3C7]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark truncate">{c.contact_name || 'Desconocido'}</p>
|
||||||
|
<p className="text-xs text-[#7A5C44] flex items-center mt-0.5">
|
||||||
|
<Phone size={10} className="mr-1" />
|
||||||
|
{c.contact_phone || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{c.unread_count > 0 && (
|
||||||
|
<Badge variant="primary">{c.unread_count}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#7A5C44] mt-1 truncate">{c.last_message_text || 'Sin mensajes'}</p>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<p className="text-[10px] text-[#A87B5D]">{c.last_message_at || c.created_at || ''}</p>
|
||||||
|
{c.assigned_agent_id && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full truncate max-w-[120px]">
|
||||||
|
Asignado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Mensajes */}
|
||||||
|
<Card className="lg:col-span-2 overflow-hidden flex flex-col">
|
||||||
|
<Card.Body className="p-0 flex flex-col h-full">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div className="p-4 border-b border-[#F5EBD8] bg-[#FEF3C7] flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark">{selected.contact_name || 'Desconocido'}</p>
|
||||||
|
<p className="text-xs text-[#7A5C44]">{selected.contact_phone}</p>
|
||||||
|
{assignedMember && (
|
||||||
|
<p className="text-xs text-blue-600 mt-0.5">
|
||||||
|
Asignado a: {assignedMember.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAssignOpen(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<UserPlus size={14} className="mr-1.5" />
|
||||||
|
{selected.assigned_agent_id ? 'Reasignar' : 'Asignar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p className="text-center text-sm text-[#A87B5D] py-8">No hay mensajes en esta conversación</p>
|
||||||
|
) : (
|
||||||
|
messages.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.external_id}
|
||||||
|
className={`flex ${
|
||||||
|
m.sender_type === 'customer' ? 'justify-start' : 'justify-end'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] sm:max-w-[70%] rounded-2xl px-4 py-2.5 text-sm ${
|
||||||
|
m.sender_type === 'customer'
|
||||||
|
? 'bg-homenest-cream-light border border-[#E9D5B7] text-homenest-bark'
|
||||||
|
: 'bg-homenest-bark text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1 opacity-80">
|
||||||
|
{senderIcon(m.sender_type)}
|
||||||
|
<span className="text-[10px] font-medium">{senderLabel(m.sender_type)}</span>
|
||||||
|
</div>
|
||||||
|
<p>{m.content_text}</p>
|
||||||
|
{m.media_url && (
|
||||||
|
<a
|
||||||
|
href={m.media_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs underline mt-1 block truncate"
|
||||||
|
>
|
||||||
|
Ver medio
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] mt-1 opacity-60 text-right">{m.created_at}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Input de respuesta */}
|
||||||
|
<div className="p-3 border-t border-[#F5EBD8] bg-[#FEF3C7]">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Input
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendReply();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={currentAgentId ? 'Escribe tu respuesta...' : 'Selecciona tu identidad de agente'}
|
||||||
|
disabled={!currentAgentId || sendingReply}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendReply}
|
||||||
|
loading={sendingReply}
|
||||||
|
disabled={!currentAgentId || !replyText.trim()}
|
||||||
|
>
|
||||||
|
<Send size={16} className="mr-1.5" />
|
||||||
|
Enviar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!currentAgentId && (
|
||||||
|
<p className="text-xs text-amber-600 mt-2">Selecciona "Responder como..." para poder responder mensajes.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-[#A87B5D]">Selecciona una conversación</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de asignación */}
|
||||||
|
<Modal
|
||||||
|
isOpen={assignOpen}
|
||||||
|
onClose={() => setAssignOpen(false)}
|
||||||
|
title="Asignar conversación"
|
||||||
|
maxWidth="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
|
||||||
|
{selected?.assigned_agent_id && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAssign(null)}
|
||||||
|
disabled={assigning}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-lg border border-red-100 bg-red-50 hover:bg-red-100 transition text-left"
|
||||||
|
>
|
||||||
|
<X size={18} className="text-red-600" />
|
||||||
|
<span className="text-sm font-medium text-red-700">Desasignar conversación</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p className="text-sm text-[#7A5C44] py-4 text-center">No hay agentes disponibles</p>
|
||||||
|
) : (
|
||||||
|
members.map((member) => (
|
||||||
|
<button
|
||||||
|
key={member.id}
|
||||||
|
onClick={() => handleAssign(member.id)}
|
||||||
|
disabled={assigning || selected?.assigned_agent_id === member.id}
|
||||||
|
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition text-left ${
|
||||||
|
selected?.assigned_agent_id === member.id
|
||||||
|
? 'border-blue-200 bg-blue-50'
|
||||||
|
: 'border-[#F5EBD8] hover:bg-[#FEF3C7]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#F5EBD8] flex items-center justify-center text-xs font-medium text-homenest-bark shrink-0">
|
||||||
|
{member.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-homenest-bark truncate">{member.name}</p>
|
||||||
|
<p className="text-xs text-[#7A5C44] truncate">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WacrmMessages;
|
||||||
745
frontend-homenest/src/services/odoo.ts
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const ODOO_BASE = import.meta.env.VITE_API_BASE_URL || '/api/odoo';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Auth: adjuntar token y manejar 401 globalmente
|
||||||
|
// ============================================================
|
||||||
|
axios.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('skeen_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers || {};
|
||||||
|
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
axios.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(error) => {
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
const onLogin = window.location.pathname === '/login';
|
||||||
|
const isLoginCall = String(error.config?.url || '').includes('/auth/login');
|
||||||
|
if (!onLogin && !isLoginCall) {
|
||||||
|
localStorage.removeItem('skeen_token');
|
||||||
|
localStorage.removeItem('skeen_user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tipos
|
||||||
|
// ============================================================
|
||||||
|
export interface DashboardStats {
|
||||||
|
appointments_today: number;
|
||||||
|
appointments_month: number;
|
||||||
|
new_patients_month: number;
|
||||||
|
total_patients: number;
|
||||||
|
revenue_month: number;
|
||||||
|
pending_payments: number;
|
||||||
|
total_wallet_points: number;
|
||||||
|
sales_today: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Appointment {
|
||||||
|
id: number;
|
||||||
|
reference: string;
|
||||||
|
patient_id: number;
|
||||||
|
patient: string;
|
||||||
|
phone: string;
|
||||||
|
service_id: number;
|
||||||
|
service: string;
|
||||||
|
service_category?: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
state: string;
|
||||||
|
payment_state: string;
|
||||||
|
price: number;
|
||||||
|
doctor_id?: number;
|
||||||
|
doctor?: string;
|
||||||
|
branch?: string;
|
||||||
|
medium?: string;
|
||||||
|
notes?: string;
|
||||||
|
package_finished?: boolean;
|
||||||
|
package_finished_date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Patient {
|
||||||
|
id: number;
|
||||||
|
patient_id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
birth_date: string | null;
|
||||||
|
age?: number;
|
||||||
|
gender?: string | false;
|
||||||
|
blood_type?: string | false;
|
||||||
|
last_visit: string | null;
|
||||||
|
total_visits: number;
|
||||||
|
wallet_points: number;
|
||||||
|
total_spent: number;
|
||||||
|
source: string;
|
||||||
|
is_vip: boolean;
|
||||||
|
primary_doctor_id?: number;
|
||||||
|
primary_doctor?: string;
|
||||||
|
// Datos personales extendidos
|
||||||
|
birthplace?: string;
|
||||||
|
occupation?: string;
|
||||||
|
marital_status?: string;
|
||||||
|
emergency_contact?: string;
|
||||||
|
emergency_phone?: string;
|
||||||
|
home_phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
address_notes?: string;
|
||||||
|
referred_by?: string;
|
||||||
|
patient_comments?: string;
|
||||||
|
internal_notes?: string;
|
||||||
|
// Historia clínica
|
||||||
|
is_pregnant?: boolean;
|
||||||
|
is_breastfeeding?: boolean;
|
||||||
|
uses_contraceptives?: boolean;
|
||||||
|
children_count?: number;
|
||||||
|
kidney_problems?: boolean;
|
||||||
|
back_pain?: boolean;
|
||||||
|
heart_disease?: boolean;
|
||||||
|
respiratory_problems?: boolean;
|
||||||
|
blood_pressure?: boolean;
|
||||||
|
diabetes?: boolean;
|
||||||
|
thyroid?: boolean;
|
||||||
|
colitis?: boolean;
|
||||||
|
constipation?: boolean;
|
||||||
|
liver_problems?: boolean;
|
||||||
|
surgeries?: boolean;
|
||||||
|
surgeries_notes?: string;
|
||||||
|
varicose_veins?: boolean;
|
||||||
|
migraine?: boolean;
|
||||||
|
faints_with_needles?: boolean;
|
||||||
|
medical_notes?: string;
|
||||||
|
// Legacy medical fields
|
||||||
|
allergies?: string;
|
||||||
|
medical_history?: string;
|
||||||
|
current_medication?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
price: number;
|
||||||
|
duration_min: number;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
service_group: string;
|
||||||
|
is_favorite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Doctor {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
job_title: string;
|
||||||
|
work_phone: string;
|
||||||
|
work_email: string;
|
||||||
|
commission_pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
list_price: number;
|
||||||
|
default_code: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wallet {
|
||||||
|
id: number;
|
||||||
|
patient_id: number;
|
||||||
|
patient: string;
|
||||||
|
phone: string;
|
||||||
|
points: number;
|
||||||
|
equivalent_mxn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
patient: string;
|
||||||
|
patient_id: number;
|
||||||
|
amount: number;
|
||||||
|
payment_method: string;
|
||||||
|
state: string;
|
||||||
|
payment_url?: string;
|
||||||
|
provider_reference?: string;
|
||||||
|
create_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaleLine {
|
||||||
|
id?: number;
|
||||||
|
service_id: number;
|
||||||
|
service?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
price_unit: number;
|
||||||
|
subtotal: number;
|
||||||
|
is_prescribed?: boolean;
|
||||||
|
prescribed_by_id?: number | null;
|
||||||
|
prescribed_by?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommissionRow {
|
||||||
|
doctor_id: number;
|
||||||
|
doctor: string;
|
||||||
|
job_title: string;
|
||||||
|
commission_pct: number;
|
||||||
|
base: number;
|
||||||
|
commission: number;
|
||||||
|
items: number;
|
||||||
|
sales: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sale {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
patient_id: number;
|
||||||
|
patient: string;
|
||||||
|
date: string;
|
||||||
|
subtotal: number;
|
||||||
|
discount: number;
|
||||||
|
tax: number;
|
||||||
|
total: number;
|
||||||
|
amount_paid: number;
|
||||||
|
amount_due: number;
|
||||||
|
state: string;
|
||||||
|
lines: SaleLine[];
|
||||||
|
refunded?: boolean;
|
||||||
|
refund_amount?: number;
|
||||||
|
refund_reason?: string;
|
||||||
|
refunded_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashClosing {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
date: string;
|
||||||
|
user: string | false;
|
||||||
|
opening_cash: number;
|
||||||
|
total_cash: number;
|
||||||
|
total_card: number;
|
||||||
|
total_transfer: number;
|
||||||
|
total_other: number;
|
||||||
|
total_sales: number;
|
||||||
|
closing_cash: number;
|
||||||
|
difference: number;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartPoint {
|
||||||
|
name: string;
|
||||||
|
appointments: number;
|
||||||
|
revenue: number;
|
||||||
|
patients: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Birthday {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
birth_date: string;
|
||||||
|
occurs_on: string;
|
||||||
|
turning_age: number;
|
||||||
|
last_visit: string | null;
|
||||||
|
total_spent: number;
|
||||||
|
amount_due: number;
|
||||||
|
is_vip: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InventoryKind = 'producto' | 'consumible';
|
||||||
|
export type InventoryLevel = 'out' | 'critical' | 'low' | 'optimal';
|
||||||
|
export type InventoryMoveType = 'compra' | 'venta' | 'baja' | 'ajuste';
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
kind: InventoryKind;
|
||||||
|
sku: string;
|
||||||
|
category: string;
|
||||||
|
unit: string;
|
||||||
|
qty: number;
|
||||||
|
qty_optimal: number;
|
||||||
|
qty_min: number;
|
||||||
|
cost: number;
|
||||||
|
inventory_value: number;
|
||||||
|
expiry_date: string | null;
|
||||||
|
last_count_date: string | null;
|
||||||
|
active: boolean;
|
||||||
|
notes: string;
|
||||||
|
stock_level: InventoryLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryMove {
|
||||||
|
id: number;
|
||||||
|
item_id: number;
|
||||||
|
type: InventoryMoveType;
|
||||||
|
qty: number;
|
||||||
|
before_qty: number;
|
||||||
|
after_qty: number;
|
||||||
|
date: string | null;
|
||||||
|
reference: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventorySummary {
|
||||||
|
levels: Record<InventoryLevel, number>;
|
||||||
|
total_value: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WACRM
|
||||||
|
export interface WacrmConversation {
|
||||||
|
id: number;
|
||||||
|
external_id: string;
|
||||||
|
contact_id?: string;
|
||||||
|
contact_name: string;
|
||||||
|
contact_phone: string;
|
||||||
|
status: string;
|
||||||
|
assigned_agent: string;
|
||||||
|
assigned_agent_id: string;
|
||||||
|
last_message_text: string;
|
||||||
|
last_message_at: string | null;
|
||||||
|
unread_count: number;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WacrmMessage {
|
||||||
|
id: number;
|
||||||
|
external_id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
contact_name: string;
|
||||||
|
contact_phone: string;
|
||||||
|
sender_type: 'customer' | 'agent' | 'bot';
|
||||||
|
content_type: string;
|
||||||
|
content_text: string;
|
||||||
|
media_url: string;
|
||||||
|
template_name: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WacrmStage {
|
||||||
|
id: number;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WacrmPipeline {
|
||||||
|
id: number;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
stages: WacrmStage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WacrmLead {
|
||||||
|
id: number;
|
||||||
|
external_id: string;
|
||||||
|
title: string;
|
||||||
|
contact_id?: string;
|
||||||
|
contact_name: string;
|
||||||
|
contact_phone: string;
|
||||||
|
pipeline: string;
|
||||||
|
pipeline_id?: number;
|
||||||
|
stage: string;
|
||||||
|
stage_id?: number;
|
||||||
|
stage_color: string;
|
||||||
|
value: number;
|
||||||
|
currency: string;
|
||||||
|
status: 'open' | 'won' | 'lost';
|
||||||
|
expected_close_date: string | null;
|
||||||
|
notes: string;
|
||||||
|
assigned_to: string;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrontendRole = 'admin' | 'recepcion' | 'medico' | 'lectura';
|
||||||
|
|
||||||
|
export interface FrontendUser {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
role: FrontendRole;
|
||||||
|
must_change_password?: boolean;
|
||||||
|
last_login?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
status: string;
|
||||||
|
message?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paginated<T> {
|
||||||
|
status: string;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_pages: number;
|
||||||
|
items: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// API
|
||||||
|
// ============================================================
|
||||||
|
export const odooApi = {
|
||||||
|
// Auth (frontend React)
|
||||||
|
async login(login: string, password: string): Promise<{ status: string; token: string; user: FrontendUser; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/auth/login`, { login, password });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async me(): Promise<{ status: string; user: FrontendUser }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/auth/me`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<{ status: string }> {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/auth/logout`);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return { status: 'success' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async listFrontendUsers(): Promise<{ status: string; users: FrontendUser[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/auth/users`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createFrontendUser(payload: { login: string; name: string; password: string; role: FrontendRole }): Promise<{ status: string; user: FrontendUser; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/auth/users`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateFrontendUser(id: number, payload: Partial<{ name: string; role: FrontendRole; active: boolean; password: string; must_change_password: boolean }>): Promise<{ status: string; user: FrontendUser; message?: string }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Health
|
||||||
|
async healthCheck(): Promise<unknown> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
async getDashboard(): Promise<{ status: string; stats: DashboardStats }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/dashboard`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWeeklyChart(): Promise<{ status: string; data: ChartPoint[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/weekly_chart`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Citas
|
||||||
|
async getAppointments(params?: Record<string, string | number>): Promise<{ status: string; appointments: Appointment[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAppointment(appointment: Partial<Appointment>): Promise<{ status: string; appointment: Appointment }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments`, appointment);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAppointment(id: number, appointment: Partial<Appointment>): Promise<{ status: string; appointment: Appointment }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}`, appointment);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAppointment(id: number): Promise<{ status: string; message: string }> {
|
||||||
|
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAppointmentStatus(id: number, action: string, reason?: string): Promise<{ status: string; appointment: Appointment }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/status`, { action, reason });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async packageFinished(id: number, finished = true): Promise<{ status: string; appointment: Appointment }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/package_finished`, { finished });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAvailableSlots(date: string, servicio_id: number): Promise<{ status: string; slots: { time: number; time_str: string }[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments/slots`, { params: { date, servicio_id } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pacientes
|
||||||
|
async getPatients(params?: string | { search?: string; page?: number; page_size?: number }): Promise<{ status: string; patients: Patient[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
|
||||||
|
const p = typeof params === 'string' ? { search: params } : params || {};
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients`, { params: p });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createPatient(patient: Partial<Patient>): Promise<{ status: string; patient: Patient }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients`, patient);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updatePatient(id: number, patient: Partial<Patient>): Promise<{ status: string; patient: Patient }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}`, patient);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPatientHistory(id: number): Promise<{ status: string; appointments: Appointment[]; sales: Sale[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}/history`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Servicios
|
||||||
|
async getServices(): Promise<{ status: string; services: Service[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/services`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createService(service: Partial<Service>): Promise<{ status: string; service: Service }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/services`, service);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateService(id: number, service: Partial<Service>): Promise<{ status: string; service: Service }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/services/${id}`, service);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Médicos
|
||||||
|
async getDoctors(): Promise<{ status: string; doctors: Doctor[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDoctor(id: number, payload: { commission_pct: number }): Promise<{ status: string; doctor: Doctor }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Comisiones
|
||||||
|
async getCommissions(start?: string, end?: string): Promise<{ status: string; start: string; end: string; total_base: number; total_commission: number; commissions: CommissionRow[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/commissions`, { params: { start, end } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Productos
|
||||||
|
async getProducts(): Promise<{ status: string; products: Product[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/products`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Monederos
|
||||||
|
async getWallets(params?: string | { phone?: string; search?: string; page?: number; page_size?: number }): Promise<{ status: string; wallets: Wallet[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
|
||||||
|
const p = typeof params === 'string' ? { phone: params } : params || {};
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wallets`, { params: p });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWalletByPhone(phone: string): Promise<{ status: string; wallet: { points: number; equivalent_mxn: number; history: unknown[] } }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wallets/${phone}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async walletTransaction(payload: { phone: string; type: 'accrual' | 'redemption'; points: number; description?: string }): Promise<{ status: string; wallet: Wallet }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wallets/transaction`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pagos
|
||||||
|
async getPayments(state?: string): Promise<{ status: string; payments: Payment[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/payments`, { params: state ? { state } : {} });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createPayment(payment: Partial<Payment>): Promise<{ status: string; payment: Payment }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/payments`, payment);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmPayment(id: number): Promise<{ status: string; payment: Payment }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/payments/${id}/confirm`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ventas
|
||||||
|
async getSales(params?: Record<string, string | number>): Promise<{ status: string; sales: Sale[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createSale(sale: Omit<Partial<Sale>, 'lines'> & { lines: Omit<SaleLine, 'subtotal'>[] }): Promise<{ status: string; sale: Sale }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales`, sale);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async paySale(id: number, payload: { payment_method: string; amount?: number }): Promise<{ status: string; sale: Sale; payment: Payment }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}/pay`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refundSale(id: number, payload: { amount?: number; reason?: string }): Promise<{ status: string; sale: Sale; result: { refund_amount: number; points_reversed: number; new_state: string; new_amount_paid: number } }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}/refund`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cortes de caja
|
||||||
|
async getCashClosings(date?: string): Promise<{ status: string; cash_closings: CashClosing[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/cash_closings`, { params: date ? { date } : {} });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCashClosing(payload: { date?: string; opening_cash: number; notes?: string }): Promise<{ status: string; cash_closing: CashClosing }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cash_closings`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async closeCashClosing(id: number, closing_cash: number): Promise<{ status: string; cash_closing: CashClosing }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cash_closings/${id}/close`, { closing_cash });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reportes
|
||||||
|
async getSalesReport(start?: string, end?: string): Promise<{ status: string; total_sales: number; total_paid: number; total_due: number; count: number }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/sales`, { params: { start, end } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAppointmentsReport(start?: string, end?: string): Promise<{ status: string; total: number; by_state: Record<string, number> }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/appointments`, { params: { start, end } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTopServices(start?: string, end?: string, limit = 8): Promise<{ status: string; start: string; end: string; services: { service_id: number; service: string; qty: number; revenue: number }[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/top_services`, { params: { start, end, limit } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCashReport(date?: string): Promise<{ status: string; total: number; by_method: Record<string, number> }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/cash`, { params: { date } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tipo de cambio
|
||||||
|
async getExchangeRate(): Promise<{ status: string; current: { rate: number; date: string; source: string } | null; history: { id: number; date: string; rate: number; source: string }[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/exchange_rate`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setExchangeRate(rate: number, source = 'manual'): Promise<{ status: string; exchange_rate: { id: number; date: string; rate: number; source: string } }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/exchange_rate`, { rate, source });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Meta de ventas mensual
|
||||||
|
async getSalesGoal(): Promise<{ status: string; goal: number; current: number; pct: number; start: string; end: string; count: number }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales_goal`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setSalesGoal(goal: number): Promise<{ status: string; goal: number }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales_goal`, { goal });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cumpleañeros
|
||||||
|
async getBirthdays(period: 'today' | 'week' | 'month' = 'month'): Promise<{ status: string; period: string; start: string; end: string; total: number; birthdays: Birthday[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/birthdays`, { params: { period } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Inventario / Consumibles
|
||||||
|
async getInventory(params?: { kind?: InventoryKind; level?: InventoryLevel; search?: string }): Promise<{ status: string; items: InventoryItem[]; summary: InventorySummary }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventory/items`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createInventoryItem(item: Partial<InventoryItem>): Promise<{ status: string; item: InventoryItem }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/inventory/items`, item);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateInventoryItem(id: number, item: Partial<InventoryItem>): Promise<{ status: string; item: InventoryItem }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/inventory/items/${id}`, item);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getInventoryMoves(id: number): Promise<{ status: string; moves: InventoryMove[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventory/items/${id}/moves`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createInventoryMove(id: number, payload: { type: InventoryMoveType; qty: number; reference?: string; notes?: string }): Promise<{ status: string; move: InventoryMove; item: InventoryItem }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/inventory/items/${id}/moves`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// WACRM Proxy
|
||||||
|
// ============================================================
|
||||||
|
async syncWacrm(): Promise<{ status: string; result: Record<string, number | string | null> }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/sync`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWacrmMembers(): Promise<{ status: string; members: { id: string; name: string; email?: string; avatar_url?: string; role: string }[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/members`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async assignWacrmConversation(external_id: string, assigned_agent_id: string | null): Promise<{ status: string; assigned_agent_id?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations/${external_id}/assign`, {
|
||||||
|
assigned_agent_id,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWacrmConversations(search?: string): Promise<{ status: string; conversations: WacrmConversation[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations`, { params: search ? { search } : {} });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendWacrmMessage(payload: { conversation_id: string; text: string; assigned_agent_id: string }): Promise<{ status: string; message: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/messages`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWacrmMessages(search?: string, conversation_id?: string): Promise<{ status: string; messages: WacrmMessage[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/messages`, {
|
||||||
|
params: { ...(search ? { search } : {}), ...(conversation_id ? { conversation_id } : {}) },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWacrmPipelines(): Promise<{ status: string; pipelines: WacrmPipeline[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/pipelines`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWacrmLeads(search?: string, status?: string, pipeline_id?: string): Promise<{ status: string; leads: WacrmLead[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/leads`, {
|
||||||
|
params: { ...(search ? { search } : {}), ...(status ? { status } : {}), ...(pipeline_id ? { pipeline_id } : {}) },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateWacrmLeadStatus(id: number, payload: { status?: 'open' | 'won' | 'lost'; stage_id?: string }): Promise<{ status: string; lead: WacrmLead }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/wacrm/leads/${id}/status`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||