Compare commits

..

5 Commits

Author SHA1 Message Date
cd3d7e816f Recuperar implementado 2026-02-27 10:08:30 -07:00
63925fe305 Login resuelto 2026-02-27 09:16:08 -07:00
c146a6c3c3 funcion dashboard y reporte de endpoints v1.16.0 2026-02-26 12:48:28 -07:00
ba779bde55 docs: guardar README original como README.legacy.md 2026-02-26 09:13:29 -07:00
ba94152074 docs: README completo para Windows/Linux/macOS + fix conflicto de puertos
- README.md reescrito con instrucciones detalladas para principiantes y expertos
  * Tabla de contenidos con 14 secciones
  * Inicio rápido con Docker (Windows, Linux, macOS)
  * Configuración de variables de entorno con explicaciones
  * Sección de desarrollo local sin Docker
  * Comandos útiles (Docker, Alembic, calidad de código, testing)
  * Solución de problemas extensa (puertos, módulos, tenant, migraciones, Node.js)
  * Historial de versiones

- Fix: conflicto de puertos cuando ambos frontends corren en local
  * frontend-internal/vite.config.js: usa PORT=3001 por defecto (3000 en Docker)
  * frontend-internal/package.json: dev script sin puerto hardcodeado
  * docker-compose.yml: frontend-internal recibe PORT=3000 como variable de env
2026-02-26 09:02:04 -07:00
16 changed files with 1765 additions and 202 deletions

178
README.legacy.md Normal file
View File

@@ -0,0 +1,178 @@
# ServiceManagerWeb - Mesa de Ayuda B2B
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
## Arquitectura
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
- **Backend**: Python FastAPI + Pydantic v2
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
- **BD**: PostgreSQL + Alembic migrations
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
- **Infra**: Docker Compose local, preparado para producción
## Estructura del Monorepo
```
ServiceManagerWeb/
├── backend/ # FastAPI app
├── frontend-client/ # SvelteKit app para clientes
├── frontend-internal/ # SvelteKit app para staff interno
├── workers/ # Celery tasks
├── db/ # Migrations y esquemas
├── docker/ # Dockerfiles específicos
├── docs/ # Documentación adicional
├── scripts/ # Scripts de desarrollo/despliegue
├── docker-compose.yml # Orquestación completa
└── .env.example # Variables de entorno
```
## Stack Tecnológico
### Backend (Python)
- FastAPI (async)
- Pydantic v2
- SQLAlchemy 2.0 (async)
- Alembic (migrations)
- Argon2 (hashing passwords)
- PyJWT
- Celery + Redis
### Frontend (JavaScript/TypeScript)
- SvelteKit
- TypeScript
- TailwindCSS
- shadcn/ui o similar
- Zod (validación)
### Infraestructura
- PostgreSQL 15+
- Redis 7+
- Docker & Docker Compose
- Nginx (reverse proxy)
## Dominios del Sistema
1. **Auth**: Usuarios, roles, permisos, 2FA
2. **Tenants**: Multi-tenancy, organizaciones
3. **Tickets**: Gestión de tickets, estados, SLAs
4. **Notifications**: Email, plantillas, logs
5. **Audit**: Bitácora de acciones
## Roles de Usuario
### Internos (Staff)
- `ADMIN`: Control total del sistema
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
- `AGENT`: Atención de tickets
- `AUDITOR`: Solo lectura para auditoría
### Clientes
- `CLIENT_ADMIN`: Gestión de organización cliente
- `CLIENT_USER`: Creación y seguimiento de tickets
## Quick Start
```bash
# Clonar y configurar
git clone <repo>
cd ServiceManagerWeb
cp .env.example .env
# Levantar servicios
docker-compose up -d
# Verificar estado
docker-compose ps
```
## URLs por Defecto
- Frontend Clientes: http://localhost:3000
- Frontend Interno: http://localhost:3001
- API Backend: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080
## Scripts de Desarrollo
```bash
# Backend
cd backend
python -m uvicorn app.main:app --reload --port 8000
# Frontend Cliente
cd frontend-client
npm run dev -- --port 3000
# Frontend Interno
cd frontend-internal
npm run dev -- --port 3001
# Workers
cd workers
celery -A app.worker worker --loglevel=info
celery -A app.worker beat --loglevel=info
```
## Testing
```bash
# Backend tests
cd backend
pytest
# Frontend tests
cd frontend-client
npm test
cd ../frontend-internal
npm test
```
## Troubleshooting
### Error 500 en Login / Proxy Error
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
**Solución**:
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
### Tenant Slug Incorrecto
**Síntoma**: Error de autenticación incluso con credenciales correctas.
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
**Solución**:
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
2. Actualizar el tenant_slug en el código de login
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
### Credenciales de Prueba
```
Email: admin@aduanasoft.com
Password: admin123
Tenant: aduanasoft-demo
Role: ADMIN
```
## Contribución
1. Fork del proyecto
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
5. Crear Pull Request
## Licencia
Propietario - Aduanasoft © 2026

837
README.md
View File

@@ -1,178 +1,755 @@
# ServiceManagerWeb - Mesa de Ayuda B2B
# ServiceManagerWeb Mesa de Ayuda B2B
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
> **Versión actual:** v1.15.1 — Módulo de reportes implementado
>
> Sistema multi-tenant de Mesa de Ayuda / Soporte Técnico empresarial desarrollado para Aduanasoft.
> Arquitectura Modular Monolith con Clean Architecture, preparado para escalar a microservicios.
## Arquitectura
---
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
- **Backend**: Python FastAPI + Pydantic v2
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
- **BD**: PostgreSQL + Alembic migrations
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
- **Infra**: Docker Compose local, preparado para producción
## Tabla de Contenidos
## Estructura del Monorepo
1. [Requisitos previos](#requisitos-previos)
2. [Inicio rápido con Docker (recomendado)](#inicio-rápido-con-docker-recomendado)
3. [Configuración de variables de entorno](#configuración-de-variables-de-entorno)
4. [Cargar datos de prueba](#cargar-datos-de-prueba)
5. [URLs y puertos por defecto](#urls-y-puertos-por-defecto)
6. [Credenciales de prueba](#credenciales-de-prueba)
7. [Desarrollo local sin Docker](#desarrollo-local-sin-docker)
8. [Arquitectura del proyecto](#arquitectura-del-proyecto)
9. [Roles y permisos](#roles-y-permisos)
10. [Comandos útiles](#comandos-útiles)
11. [Pruebas (testing)](#pruebas-testing)
12. [Solución de problemas](#solución-de-problemas)
13. [Contribución](#contribución)
14. [Historial de versiones](#historial-de-versiones)
---
## Requisitos previos
Antes de clonar el proyecto, asegúrate de tener instalado:
| Herramienta | Versión mínima | Descarga |
|-------------|---------------|---------|
| **Git** | 2.x | https://git-scm.com/downloads |
| **Docker Desktop** | 24.x | https://www.docker.com/products/docker-desktop |
| **Docker Compose** | v2.x (incluido en Docker Desktop) | — |
> **Nota para desarrolladores que quieran editar código localmente (sin Docker):**
> también necesitarás Python 3.11+ y Node.js 18+. Ver sección
> [Desarrollo local sin Docker](#desarrollo-local-sin-docker).
### Verificar que Docker esté corriendo
```bash
docker --version # Debe mostrar Docker version 24.x o superior
docker compose version # Debe mostrar Docker Compose version v2.x
```
Si `docker compose version` falla, prueba `docker-compose --version` (versión standalone).
---
## Inicio rápido con Docker (recomendado)
Este es el método más simple y funciona igual en **Windows, Linux y macOS**.
Solo necesitas Docker Desktop instalado y corriendo.
### Paso 1 — Clonar el repositorio
```bash
git clone https://git.aduanasoft.com/ADUANASOFT/service_manager.git
cd service_manager
```
### Paso 2 — Crear el archivo de variables de entorno
**Linux / macOS:**
```bash
cp .env.example .env
```
**Windows (PowerShell):**
```powershell
Copy-Item .env.example .env
```
**Windows (CMD):**
```cmd
copy .env.example .env
```
> **Importante:** El archivo `.env` nunca se sube a git (está en `.gitignore`).
> Para desarrollo local los valores del `.env.example` funcionan sin cambios.
> En producción **debes** generar claves secretas únicas (ver sección de variables de entorno).
### Paso 3 — Levantar todos los servicios
```bash
docker compose up -d
```
Este comando descarga las imágenes, construye los contenedores e inicia todo el stack.
La primera vez tarda entre 3 y 8 minutos dependiendo de la conexión a internet.
> **Alternativa con herramientas de desarrollo** (Adminer, MailHog, Redis Commander):
> ```bash
> docker compose --profile dev up -d
> ```
### Paso 4 — Verificar que todo esté funcionando
```bash
docker compose ps
```
Deberías ver todos los servicios con estado `Up` o `healthy`:
```
NAME STATUS
servicemanager-db Up (healthy)
servicemanager-redis Up (healthy)
servicemanager-backend Up (healthy)
servicemanager-worker Up
servicemanager-beat Up
servicemanager-client-frontend Up
servicemanager-internal-... Up
servicemanager-nginx Up
```
Si algún servicio muestra `Exit` o `Restarting`, revisa la sección
[Solución de problemas](#solución-de-problemas).
### Paso 5 — Cargar datos de ejemplo (opcional pero recomendado)
```bash
docker exec servicemanager-backend python /scripts/seed_data.py
```
Esto crea el tenant de demostración, categorías, usuarios y tickets de prueba.
### ¡Listo! Abre el navegador
| Aplicación | URL |
|------------|-----|
| Portal de clientes | http://localhost:3000 |
| Panel interno (staff) | http://localhost:3001 |
| API REST | http://localhost:8000 |
| Documentación API (Swagger) | http://localhost:8000/docs |
| Documentación API (ReDoc) | http://localhost:8000/redoc |
| Health check | http://localhost:8000/health |
> **Con perfil dev** activo también tendrás:
> - Adminer (gestor visual de PostgreSQL): http://localhost:8080
> - MailHog (pruebas de email): http://localhost:8025
> - Redis Commander (inspector de Redis): http://localhost:8081
---
## Configuración de variables de entorno
El archivo `.env` controla todo el comportamiento de la aplicación.
Copia `.env.example` como `.env` y revisa los valores siguientes:
### Variables críticas
| Variable | Descripción | Valor por defecto (dev) |
|----------|-------------|-------------------------|
| `SECRET_KEY` | Clave secreta general de Flask/FastAPI | _(cambiar en producción)_ |
| `JWT_SECRET_KEY` | Clave para firmar tokens JWT | _(cambiar en producción)_ |
| `DATABASE_URL` | Cadena de conexión a PostgreSQL | `postgresql+asyncpg://servicemanager:...@postgres:5432/servicemanager` |
| `REDIS_URL` | URL de conexión a Redis | `redis://redis:6379/0` |
| `ENVIRONMENT` | Entorno actual | `development` |
| `DEBUG` | Modo debug (muestra errores detallados) | `true` |
### Generar claves seguras para producción
**Linux / macOS:**
```bash
openssl rand -base64 32 # Genera SECRET_KEY
openssl rand -base64 32 # Genera JWT_SECRET_KEY
```
**Windows (PowerShell):**
```powershell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
> **Advertencia:** Nunca uses las claves del `.env.example` en producción.
> Cambiar las claves en producción invalida todas las sesiones activas.
### Desarrollo local vs Docker
En `.env.example` las URLs apuntan a nombres de servicio Docker (`postgres`, `redis`, `backend`).
Si ejecutas el backend directamente en tu máquina (sin Docker), cambia:
```dotenv
# Para desarrollo local sin Docker:
DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager
REDIS_URL=redis://localhost:6379/0
CELERY_BROKER_URL=redis://localhost:6379/0
```
---
## Cargar datos de prueba
El script `seed_data.py` crea datos iniciales en la base de datos.
**Con Docker (recomendado):**
```bash
docker exec servicemanager-backend python /scripts/seed_data.py
```
**Sin Docker:**
```bash
cd backend
python ../scripts/seed_data.py
```
El script crea:
- Tenant de demostración: `aduanasoft-demo`
- Categorías de tickets (Soporte Técnico, Facturación, Incidentes Críticos, etc.)
- Sistemas registrados
- Usuarios de prueba con distintos roles
---
## URLs y puertos por defecto
| Servicio | Puerto | Descripción |
|----------|--------|-------------|
| Frontend Clientes | **3000** | Portal para usuarios clientes |
| Frontend Interno | **3001** | Panel para staff (agentes, admins) |
| Backend API | **8000** | FastAPI — endpoints REST |
| PostgreSQL | **5432** | Base de datos (no exponer en producción) |
| Redis | **6379** | Cache y broker Celery (no exponer en producción) |
| Nginx | **80** | Reverse proxy |
| Adminer *(perfil dev)* | **8080** | GUI para PostgreSQL |
| MailHog *(perfil dev)* | **8025** | Capturador de emails en desarrollo |
| Redis Commander *(perfil dev)* | **8081** | GUI para Redis |
### ¿Conflicto de puertos?
Si algún puerto ya está en uso en tu máquina, edita `docker-compose.yml` y cambia
el número **izquierdo** del mapeo `host:container`. Por ejemplo, para backend en el 8080:
```yaml
ports:
- "8080:8000" # ahora accesible en localhost:8080
```
---
## Credenciales de prueba
Después de ejecutar el seed, puedes iniciar sesión con:
| Campo | Valor |
|-------|-------|
| Email | `admin@aduanasoft.com` |
| Contraseña | `admin123` |
| Tenant | `aduanasoft-demo` |
| Rol | `ADMIN` |
> Otros usuarios creados por el seed tienen el mismo sufijo de contraseña (`123`).
> Revisa `scripts/seed_data.py` para ver la lista completa.
---
## Desarrollo local sin Docker
Útil cuando necesitas depurar el código con breakpoints o acelerar el ciclo de desarrollo.
Requiere que **PostgreSQL y Redis sí corran en Docker** (o instalación nativa).
### Requisitos adicionales
| Herramienta | Versión | Descarga |
|------------|---------|---------|
| Python | 3.11 o 3.12 | https://www.python.org/downloads/ |
| Node.js (con npm) | 18 LTS | https://nodejs.org/ |
| pip | incluido con Python | — |
### Iniciar solo la base de datos y Redis
```bash
docker compose up -d postgres redis
```
### Backend (FastAPI)
```bash
cd backend
# Crear entorno virtual (solo la primera vez)
python -m venv ../.venv
# Activar entorno virtual
# Linux / macOS:
source ../.venv/bin/activate
# Windows (PowerShell):
..\.venv\Scripts\Activate.ps1
# Windows (CMD):
..\.venv\Scripts\activate.bat
# Instalar dependencias (solo la primera vez o cuando cambie requirements.txt)
pip install -r requirements.txt
# Ejecutar migraciones de base de datos
alembic upgrade head
# Iniciar servidor de desarrollo
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
> Si `uvicorn` no se encuentra, asegúrate de que el entorno virtual está activado
> (`(.venv)` debe aparecer en tu terminal).
### Frontend Clientes
```bash
cd frontend-client
# Instalar dependencias (solo la primera vez)
npm install
# Iniciar servidor de desarrollo en puerto 3000
npm run dev
```
### Frontend Interno (staff)
```bash
cd frontend-internal
# Instalar dependencias (solo la primera vez)
npm install
# Iniciar servidor de desarrollo en puerto 3001
npm run dev
```
> Los dos frontends tienen puertos distintos (3000 y 3001) para que no haya conflicto
> cuando corren al mismo tiempo.
### Workers Celery (opcional en desarrollo)
Necesario solo si desarrollas funcionalidades de notificaciones o SLAs automáticos.
```bash
cd workers
# Activar el mismo entorno virtual del backend:
# Linux / macOS:
source ../.venv/bin/activate
# Windows:
..\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
# Worker principal
celery -A app.celery worker --loglevel=info
# Scheduler de tareas periódicas (en otra terminal)
celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule
```
---
## Arquitectura del proyecto
```
ServiceManagerWeb/
├── backend/ # FastAPI app
├── frontend-client/ # SvelteKit app para clientes
├── frontend-internal/ # SvelteKit app para staff interno
├── workers/ # Celery tasks
├── db/ # Migrations y esquemas
├── docker/ # Dockerfiles específicos
├── docs/ # Documentación adicional
├── scripts/ # Scripts de desarrollo/despliegue
├── docker-compose.yml # Orquestación completa
└── .env.example # Variables de entorno
├── backend/ # Aplicación FastAPI (Python 3.11)
│ ├── app/
│ │ ├── main.py # Punto de entrada, lifespan, middlewares
├── api/v1/
├── router.py # Registro de todos los routers
└── endpoints/ # Endpoints REST por dominio
│ │ ├── core/ # Config, seguridad, base de datos, caché
│ │ ├── models/ # Modelos SQLAlchemy (ORM)
│ │ ├── services/ # Lógica de negocio
│ │ └── middleware/ # Tenant context, Correlation ID
│ ├── migrations/ # Migraciones Alembic
│ ├── tests/ # Pruebas backend
│ └── requirements.txt # Dependencias Python
├── frontend-client/ # Portal de clientes (SvelteKit + TypeScript)
│ └── src/routes/ # Páginas: login, tickets, perfil
├── frontend-internal/ # Panel de staff (SvelteKit + TypeScript)
│ └── src/routes/ # Páginas: dashboard, tickets, reportes, auditoría
├── workers/ # Tareas asíncronas Celery
│ └── app/tasks/ # email_tasks.py, sla_tasks.py, etc.
├── docker/ # Dockerfiles y configuración Nginx
├── db/ # schema.sql inicial
├── docs/ # Documentación técnica adicional
├── scripts/ # seed_data.py, setup-dev.sh, etc.
├── docker-compose.yml # Orquestación completa
└── .env.example # Plantilla de variables de entorno
```
## Stack Tecnológico
### Stack tecnológico
### Backend (Python)
- FastAPI (async)
- Pydantic v2
- SQLAlchemy 2.0 (async)
- Alembic (migrations)
- Argon2 (hashing passwords)
- PyJWT
- Celery + Redis
**Backend:** Python 3.11 · FastAPI · Pydantic v2 · SQLAlchemy 2.0 (async) · Alembic · Argon2 · PyJWT · Celery · Redis
### Frontend (JavaScript/TypeScript)
- SvelteKit
- TypeScript
- TailwindCSS
- shadcn/ui o similar
- Zod (validación)
**Frontend:** Node.js 18 · SvelteKit · TypeScript · TailwindCSS · Zod
### Infraestructura
- PostgreSQL 15+
- Redis 7+
- Docker & Docker Compose
- Nginx (reverse proxy)
**Infraestructura:** PostgreSQL 15 · Redis 7 · Docker Compose · Nginx
## Dominios del Sistema
---
1. **Auth**: Usuarios, roles, permisos, 2FA
2. **Tenants**: Multi-tenancy, organizaciones
3. **Tickets**: Gestión de tickets, estados, SLAs
4. **Notifications**: Email, plantillas, logs
5. **Audit**: Bitácora de acciones
## Roles y permisos
## Roles de Usuario
### Internos (Staff)
- `ADMIN`: Control total del sistema
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
- `AGENT`: Atención de tickets
- `AUDITOR`: Solo lectura para auditoría
### Personal interno (staff)
| Rol | Descripción |
|-----|-------------|
| `ADMIN` | Control total del sistema |
| `SUPPORT_MANAGER` | Gestión de equipos y configuración de SLAs |
| `AGENT` | Atención y resolución de tickets |
| `AUDITOR` | Solo lectura para revisiones y cumplimiento |
### Clientes
- `CLIENT_ADMIN`: Gestión de organización cliente
- `CLIENT_USER`: Creación y seguimiento de tickets
| Rol | Descripción |
|-----|-------------|
| `CLIENT_ADMIN` | Gestión de su organización cliente |
| `CLIENT_USER` | Creación y seguimiento de sus propios tickets |
## Quick Start
---
## Comandos útiles
### Docker Compose
```bash
# Clonar y configurar
git clone <repo>
cd ServiceManagerWeb
cp .env.example .env
# Levantar todos los servicios (segundo plano)
docker compose up -d
# Levantar servicios
docker-compose up -d
# Levantar con herramientas de desarrollo
docker compose --profile dev up -d
# Verificar estado
docker-compose ps
# Ver logs en tiempo real de todos los servicios
docker compose logs -f
# Ver logs de un servicio específico
docker compose logs -f backend
docker compose logs -f frontend-internal
# Detener todos los servicios (mantiene los datos)
docker compose down
# Detener Y borrar todos los volúmenes (¡borra la base de datos!)
docker compose down -v
# Reconstruir imagen de un servicio (después de cambiar Dockerfile o requirements)
docker compose build backend
docker compose up -d backend
# Reiniciar un servicio
docker compose restart backend
```
## URLs por Defecto
- Frontend Clientes: http://localhost:3000
- Frontend Interno: http://localhost:3001
- API Backend: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080
## Scripts de Desarrollo
### Base de datos (Alembic)
```bash
# Backend
# Aplicar todas las migraciones pendientes
cd backend
python -m uvicorn app.main:app --reload --port 8000
alembic upgrade head
# Frontend Cliente
cd frontend-client
npm run dev -- --port 3000
# Ver estado de migraciones
alembic current
# Frontend Interno
cd frontend-internal
npm run dev -- --port 3001
# Revertir última migración
alembic downgrade -1
# Workers
cd workers
celery -A app.worker worker --loglevel=info
celery -A app.worker beat --loglevel=info
# Crear nueva migración (después de modificar models/)
alembic revision --autogenerate -m "nombre descriptivo del cambio"
# Con Docker:
docker exec servicemanager-backend alembic upgrade head
```
## Testing
### Calidad de código
```bash
# Backend tests
cd backend
# Linter y auto-fix
ruff check . --fix
# Formateador
black .
# Verificación de tipos
mypy .
# Todo de una vez
ruff check . --fix && black . && mypy .
```
---
## Pruebas (testing)
### Backend
```bash
cd backend
# Ejecutar todas las pruebas
pytest
# Frontend tests
cd frontend-client
npm test
cd ../frontend-internal
npm test
# Con cobertura detallada
pytest --cov=app --cov-report=html
# Abrir reporte de cobertura (Linux/macOS)
open htmlcov/index.html
# Windows
start htmlcov/index.html
# Prueba específica
pytest tests/test_auth.py -v
# Con Docker
docker exec servicemanager-backend pytest -v --cov=app
```
## Troubleshooting
### Error 500 en Login / Proxy Error
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
**Solución**:
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
### Tenant Slug Incorrecto
**Síntoma**: Error de autenticación incluso con credenciales correctas.
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
**Solución**:
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
2. Actualizar el tenant_slug en el código de login
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
### Credenciales de Prueba
### Frontend
```bash
cd frontend-internal # o frontend-client
npm test # Ejecutar una vez
npm run test:watch # Modo observador
```
Email: admin@aduanasoft.com
Password: admin123
Tenant: aduanasoft-demo
Role: ADMIN
---
## Solución de problemas
### El backend no inicia — error en `DATABASE_URL`
**Síntoma:** El contenedor `servicemanager-backend` reinicia continuamente.
**Causa frecuente:** El archivo `.env` no existe o tiene `DATABASE_URL` apuntando a `localhost`
en lugar del nombre del servicio Docker `postgres`.
**Solución:**
```bash
# Verificar que .env existe
ls .env # Linux/macOS
dir .env # Windows
# Si no existe, crearlo
cp .env.example .env # Linux/macOS
Copy-Item .env.example .env # Windows PowerShell
# Verificar el valor correcto en .env:
# DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
# ^^^^^^^
# Nombre de servicio Docker, NO localhost
```
---
### Error 500 en login / "connect ECONNREFUSED"
**Síntoma:** El frontend muestra error 500 al hacer login, o la consola del navegador
muestra `ECONNREFUSED 127.0.0.1:8000`.
**Causa:** El proxy de Vite no encuentra el backend.
**Solución en Docker:** El proxy ya está configurado para usar `PUBLIC_API_URL`.
Verifica en `docker-compose.yml` que `frontend-internal` y `frontend-client` tienen:
```yaml
environment:
- PUBLIC_API_URL=http://backend:8000
```
Después reinicia:
```bash
docker compose restart frontend-internal frontend-client
```
**Solución en desarrollo local:** Asegúrate de que el backend está corriendo:
```bash
curl http://localhost:8000/health
# Debe responder: {"status": "ok", ...}
```
---
### El frontend-internal y frontend-client usan el mismo puerto localmente
**Síntoma:** Al correr ambos frontends sin Docker, uno de los dos falla
con `Port 3000 is already in use`.
**Solución:**
- `frontend-client` → usa el puerto **3000** (por defecto con `npm run dev`)
- `frontend-internal` → usa el puerto **3001** (configurado en `vite.config.js`)
Nunca hay conflicto si los iniciaste con `npm run dev` en cada carpeta por separado.
Si aún hay conflicto, mata el proceso en ese puerto:
```bash
# Linux / macOS
lsof -ti:3000 | xargs kill -9
# Windows (PowerShell)
Get-Process -Id (Get-NetTCPConnection -LocalPort 3000).OwningProcess | Stop-Process -Force
```
---
### El tenant slug es incorrecto al hacer login
**Síntoma:** Login falla con "credenciales inválidas" aunque el email y contraseña son correctos.
**Causa:** El campo `tenant_slug` no corresponde a ningún tenant en la base de datos.
**Solución:**
```bash
# Ver los tenants disponibles
docker exec servicemanager-backend python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text
import os
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with AsyncSession(engine) as s:
result = await s.execute(text('SELECT slug, name FROM tenants'))
for row in result:
print(row)
asyncio.run(main())
"
```
Tenant por defecto (después del seed): **`aduanasoft-demo`**
---
### Puerto ocupado — cambiar puertos de los servicios
Edita `docker-compose.yml` y modifica **solo el número izquierdo** del mapeo de puertos:
```yaml
# Ejemplo: mover el backend al puerto 9000
backend:
ports:
- "9000:8000" # accesible en localhost:9000
# Ejemplo: mover el frontend al puerto 4000
frontend-client:
ports:
- "4000:3000" # accesible en localhost:4000
```
---
### Migraciones fallidas — `alembic upgrade head` da error
```bash
# Verificar el estado actual
docker exec servicemanager-backend alembic current
# Si hay conflicto, hacer downgrade hasta la base y volver a subir
docker exec servicemanager-backend alembic downgrade base
docker exec servicemanager-backend alembic upgrade head
```
---
### Módulo Python no encontrado (`ModuleNotFoundError`)
**Con Docker:** El módulo no está en `requirements.txt` o la imagen no fue reconstruida.
```bash
# Reconstruir la imagen del backend
docker compose build backend
docker compose up -d backend
```
**Local:** El entorno virtual no está activado.
```bash
# Verificar que el venv está activo (debe aparecer (.venv) en el prompt)
which python # Linux/macOS — debe apuntar a .venv/
# Windows:
where python # debe apuntar a .venv\Scripts\python.exe
```
---
### `npm: command not found` o versión de Node incorrecta
```bash
node --version # Debe ser v18.x o superior
npm --version # Debe ser 9.x o superior
```
Si Node no está instalado, descárgalo desde https://nodejs.org/ (elige "LTS").
En macOS con Homebrew:
```bash
brew install node@18
```
En Linux (Ubuntu/Debian):
```bash
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
```
---
### `docker-compose` no se reconoce como comando
En versiones modernas de Docker Desktop, el comando es `docker compose` (con espacio, sin guion).
Si tienes instalación separada de Docker Compose v1, usa `docker-compose` (con guion).
---
### Logs de los contenedores
```bash
# Ver qué está fallando
docker compose logs backend --tail=50
docker compose logs frontend-internal --tail=50
docker compose logs postgres --tail=20
```
---
## Contribución
1. Fork del proyecto
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
5. Crear Pull Request
1. Haz fork del proyecto
2. Crea una rama de funcionalidad: `git checkout -b feature/nombre-funcionalidad`
3. Realiza tus cambios siguiendo las convenciones del proyecto
4. Ejecuta las pruebas: `pytest` y el linter: `ruff check .`
5. Haz commit con un mensaje descriptivo: `git commit -m "feat: agregar exportación a CSV"`
6. Sube tu rama: `git push origin feature/nombre-funcionalidad`
7. Abre un Pull Request hacia `main`
### Convenciones de nombres
- **Modelos**: `PascalCase``User`, `Ticket`, `TenantOrganization`
- **Endpoints (URL)**: `kebab-case``/api/v1/user-management/`
- **Componentes Svelte**: `PascalCase.svelte``TicketCard.svelte`
- **Stores**: `camelCase``ticketStore.ts`
---
## Historial de versiones
| Versión | Descripción |
|---------|-------------|
| **v1.15.1** | Módulo de reportes implementado |
| v1.14.x | Mejoras al módulo de auditoría |
| v1.13.x | Sistema de SLAs automático |
| v1.12.x | Notificaciones por email |
| v1.0.0 | MVP inicial — tickets, tenants, autenticación |
---
## Licencia
Propietario - Aduanasoft © 2026
Propietario Aduanasoft © 2026. Todos los derechos reservados.

View File

@@ -0,0 +1,49 @@
"""
Script para resetear contraseñas de todos los usuarios a valores conocidos.
Ejecutar con: python -m scripts.reset_passwords (desde /app en el contenedor)
"""
import asyncio
from sqlalchemy import select, update
from app.core.database import AsyncSessionLocal
from app.core.security import security
from app.models.user import User
# Mapa email -> nueva contraseña
PASSWORD_MAP = {
"admin@aduanasoft.com": "admin123",
"admin@test.com": "admin123",
"manager@aduanasoft.com": "manager123",
"agente@aduanasoft.com": "agente123",
"auditor1@test.com": "auditor123",
"admin-cliente@empresa-demo.com": "clienteadmin123",
"cliente@empresa-demo.com": "cliente123",
"test_user@aduanasoft.com": "test123",
}
async def reset_all_passwords():
async with AsyncSessionLocal() as db:
result = await db.execute(select(User))
users = result.scalars().all()
updated = 0
skipped = 0
for user in users:
if user.email in PASSWORD_MAP:
plain = PASSWORD_MAP[user.email]
user.password_hash = security.hash_password(plain)
user.email_verified = True
user.is_active = True
updated += 1
print(f"{user.email}{plain}")
else:
skipped += 1
print(f" ⚠️ {user.email} (sin contraseña definida, se omite)")
await db.commit()
print(f"\nResumen: {updated} actualizados, {skipped} omitidos")
print("\n📋 Credenciales listas:")
for email, pwd in PASSWORD_MAP.items():
print(f" {email} / {pwd}")
if __name__ == "__main__":
asyncio.run(reset_all_passwords())

View File

@@ -415,18 +415,19 @@ INSERT INTO tenants (name, slug, contact_email) VALUES
('Aduanasoft Demo', 'aduanasoft-demo', 'demo@aduanasoft.com');
-- Usuario admin por defecto (password: admin123)
-- Hash generado con Argon2: $argon2id$v=19$m=65536,t=3,p=4$...
-- Hash Argon2id generado con m=65536,t=3,p=4
INSERT INTO users (tenant_id, email, first_name, last_name, password_hash, role, is_active, email_verified)
SELECT
id,
'admin@aduanasoft.com',
'Admin',
'Sistema',
'$argon2id$v=19$m=65536,t=3,p=4$example_hash_here',
'$argon2id$v=19$m=65536,t=3,p=4$wpjz/t+bM4bQmtM6B6A0pg$ELwnGUL4S1Y6tywp0LS6cre0bvWEoVuJ845spZ9Z9IQ',
'ADMIN',
true,
true
FROM tenants WHERE slug = 'aduanasoft-demo';
FROM tenants WHERE slug = 'aduanasoft-demo'
ON CONFLICT (tenant_id, email) DO NOTHING;
-- Categorías por defecto
INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours)

View File

@@ -164,6 +164,8 @@ services:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Cliente
- PORT=3000
- HMR_CLIENT_PORT=3000
volumes:
- ./frontend-client:/app
- /app/node_modules
@@ -189,6 +191,8 @@ services:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Admin
- PORT=3000
- HMR_CLIENT_PORT=3001
volumes:
- ./frontend-internal:/app
- /app/node_modules

View File

@@ -4,14 +4,26 @@
import Toast from '$lib/components/Toast.svelte';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import '../app.css';
let mounted = false;
onMount(() => {
auth.init();
mounted = true;
});
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register');
// Guard reactivo global: redirige a /login si no está autenticado en rutas protegidas
const publicRoutes = ['/login', '/register', '/forgot-password', '/reset-password'];
$: if (browser && mounted && !$auth.isAuthenticated &&
!publicRoutes.some(r => $page.url.pathname.startsWith(r))) {
goto('/login');
}
$: showHeader = !publicRoutes.some(r => $page.url.pathname.startsWith(r));
</script>
<div class="min-h-screen bg-gray-50 font-sans">

View File

@@ -216,12 +216,13 @@
>Recordar en este equipo</label
>
</div>
<a
href="/forgot-password"
class="text-sm font-medium text-blue-600 hover:text-blue-500"
<button
type="button"
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
on:click={() => goto('/forgot-password')}
>
Olvide mi clave
</a>
</button>
</div>
</div>
{:else}

View File

@@ -10,6 +10,16 @@ export default defineConfig({
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor en el mismo puerto 3000
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000 --host 0.0.0.0",
"dev": "vite dev --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --port 3000 --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",

View File

@@ -72,6 +72,11 @@
name: 'Seguridad',
href: '/audit/security',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
},
{
name: 'Reporte Endpoints',
href: '/test-report',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18'
}
);
}

View File

@@ -0,0 +1,161 @@
import { writable } from 'svelte/store';
/** All available dashboard modules */
export interface DashboardModule {
id: string;
title: string;
description: string;
icon: string;
href: string;
color: string;
/** Minimum role required to see this module */
roles: string[];
}
export const ALL_MODULES: DashboardModule[] = [
{
id: 'tenants',
title: 'Clientes',
description: 'Gestión de organizaciones y tenants',
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
href: '/tenants',
color: 'bg-blue-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'users',
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
href: '/users',
color: 'bg-green-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'tickets',
title: 'Tickets',
description: 'Gestión y seguimiento de tickets de soporte',
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
href: '/tickets',
color: 'bg-indigo-600',
roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT']
},
{
id: 'systems',
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
href: '/systems',
color: 'bg-gray-700',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'categories',
title: 'Categorías',
description: 'Clasificación de tickets por área',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
href: '/categories',
color: 'bg-orange-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'sla',
title: 'SLA Management',
description: 'Monitoreo de tiempos de respuesta y SLAs',
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
href: '/sla',
color: 'bg-teal-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'reports',
title: 'Reportes',
description: 'Informes estadísticos y análisis de rendimiento',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
href: '/reports',
color: 'bg-purple-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'audit',
title: 'Auditoría',
description: 'Bitácora de acciones y trazabilidad del sistema',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
href: '/audit',
color: 'bg-red-700',
roles: ['ADMIN', 'AUDITOR']
},
{
id: 'security',
title: 'Seguridad',
description: 'Análisis de amenazas y eventos de seguridad',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
href: '/audit/security',
color: 'bg-yellow-600',
roles: ['ADMIN']
},
{
id: 'endpoints',
title: 'Reporte de Endpoints',
description: 'Estado y diagnóstico de todos los endpoints API',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18',
href: '/test-report',
color: 'bg-cyan-600',
roles: ['ADMIN']
}
];
const STORAGE_KEY = 'dashboard_module_visibility';
function getInitialVisibility(): Record<string, boolean> {
if (typeof window === 'undefined') {
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
}
function createDashboardConfig() {
const { subscribe, set, update } = writable<Record<string, boolean>>(getInitialVisibility());
return {
subscribe,
toggle(id: string) {
update(state => {
const next = { ...state, [id]: !state[id] };
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
return next;
});
},
setVisible(id: string, visible: boolean) {
update(state => {
const next = { ...state, [id]: visible };
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
return next;
});
},
showAll() {
const all = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
set(all);
},
reset() {
const defaults = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
}
set(defaults);
}
};
}
export const dashboardConfig = createDashboardConfig();

View File

@@ -5,14 +5,24 @@
import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import '../app.css';
let sidebarOpen = false;
let mounted = false;
onMount(() => {
auth.init();
mounted = true;
});
// Guard reactivo global: redirige a /login si no está autenticado
$: if (browser && mounted && !$auth.isAuthenticated && $page.url.pathname !== '/login') {
goto('/login');
}
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}

View File

@@ -2,44 +2,27 @@
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte';
import { dashboardConfig, ALL_MODULES, type DashboardModule } from '$lib/stores/dashboardConfig.js';
let showSettings = false;
onMount(() => {
if (!$auth.isAuthenticated) {
goto('/login');
}
});
const cards = [
{
title: 'Clientes',
description: 'Gestión de organizaciones y tenants',
icon: 'users',
href: '/tenants',
color: 'bg-blue-600'
},
{
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'user-plus',
href: '/users',
color: 'bg-green-600'
},
{
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'server',
href: '/systems',
color: 'bg-gray-700'
},
{
title: 'Categorías',
description: 'Clasificación de tickets',
icon: 'tag',
href: '/categories',
color: 'bg-orange-600'
}
];
const role = $auth.user?.role ?? '';
/** Only modules the current role can access */
$: accessibleModules = ALL_MODULES.filter(m => m.roles.includes(role) || role === 'ADMIN');
/** Modules that are visible (enabled by user + accessible by role) */
$: visibleModules = accessibleModules.filter(m => $dashboardConfig[m.id] !== false);
function toggleSettings() {
showSettings = !showSettings;
}
</script>
<svelte:head>
@@ -47,40 +30,106 @@
</svelte:head>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="md:flex md:items-center md:justify-between">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
Panel de Administración
</h2>
<p class="mt-1 text-sm text-gray-500">
Bienvenido al sistema de gestión interna.
Bienvenido al sistema de gestión interna.
</p>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4 gap-2">
<button
on:click={toggleSettings}
class="inline-flex items-center gap-1.5 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configurar
</button>
</div>
</div>
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{#each cards as card}
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
<div class="p-5">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{card.title}
</dt>
<dd>
<div class="text-xs text-gray-900 font-light mt-1">
{card.description}
</div>
</dd>
</dl>
<!-- Settings Panel -->
{#if showSettings}
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">Módulos visibles en el dashboard</h3>
<div class="flex gap-2">
<button
on:click={() => dashboardConfig.showAll()}
class="text-xs text-blue-600 hover:text-blue-800 underline"
>
Mostrar todos
</button>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="font-medium text-blue-700 hover:text-blue-900">
Ver detalles
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{#each accessibleModules as mod}
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 {$dashboardConfig[mod.id] !== false ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
<input
type="checkbox"
checked={$dashboardConfig[mod.id] !== false}
on:change={() => dashboardConfig.toggle(mod.id)}
class="rounded text-blue-600 focus:ring-blue-500"
/>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {mod.color} flex-shrink-0"></span>
<span class="text-sm font-medium text-gray-800 truncate">{mod.title}</span>
</div>
</div>
</label>
{/each}
</div>
<p class="mt-3 text-xs text-gray-400">Las preferencias se guardan automáticamente en este navegador.</p>
</div>
{/if}
<!-- Module Cards -->
{#if visibleModules.length === 0}
<div class="mt-10 text-center py-16 bg-white rounded-lg border-2 border-dashed border-gray-200">
<svg class="mx-auto h-10 w-10 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<p class="mt-3 text-sm text-gray-500">No hay módulos visibles.</p>
<button on:click={() => dashboardConfig.showAll()} class="mt-3 text-sm text-blue-600 hover:underline">
Restaurar todos los módulos
</button>
</div>
{:else}
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each visibleModules as mod}
<a
href={mod.href}
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-all duration-200 cursor-pointer group flex flex-col"
>
<div class="p-5 flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="w-9 h-9 rounded-lg {mod.color} flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={mod.icon} />
</svg>
</div>
<span class="text-sm font-semibold text-gray-800 group-hover:text-blue-700 transition-colors">
{mod.title}
</span>
</div>
<p class="text-xs text-gray-500 leading-relaxed">{mod.description}</p>
</div>
<div class="bg-gray-50 px-5 py-2.5 border-t border-gray-100">
<span class="text-xs font-medium text-blue-600 group-hover:text-blue-800 transition-colors">
Abrir módulo →
</span>
</div>
</div>
</a>
{/each}
</div>
</a>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,492 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
onMount(() => {
if (!$auth.isAuthenticated) goto('/login');
});
// ─── Types ──────────────────────────────────────────────────────────────────
interface EndpointDef {
id: string;
label: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
path: string;
description: string;
}
interface EndpointResult {
status: number | null;
ok: boolean | null;
ms: number | null;
error: string | null;
preview: string | null;
tested: boolean;
loading: boolean;
}
interface PageDef {
label: string;
href: string;
description: string;
roles: string[];
}
// ─── Backend Endpoints ───────────────────────────────────────────────────────
const GROUPS: { name: string; color: string; endpoints: EndpointDef[] }[] = [
{
name: 'Auth',
color: 'bg-purple-100 text-purple-800',
endpoints: [
{ id: 'auth-me', label: 'Perfil actual', method: 'GET', path: '/auth/me', description: 'Información del usuario autenticado' },
{ id: 'auth-refresh', label: 'Refrescar token', method: 'POST', path: '/auth/refresh', description: 'Renovar access token (POST)' },
]
},
{
name: 'Health',
color: 'bg-green-100 text-green-800',
endpoints: [
{ id: 'health', label: 'Health Check', method: 'GET', path: '/health', description: 'Estado general del sistema' },
{ id: 'health-details', label: 'Health Detallado', method: 'GET', path: '/health/detailed', description: 'Estado con detalle de dependencias' },
]
},
{
name: 'Tenants',
color: 'bg-blue-100 text-blue-800',
endpoints: [
{ id: 'tenants-list', label: 'Listar Tenants', method: 'GET', path: '/tenants/', description: 'Todos los tenants registrados' },
{ id: 'tenant-stats', label: 'Stats Tenant', method: 'GET', path: '/tenants/stats', description: 'Estadísticas globales de tenants' },
]
},
{
name: 'Users',
color: 'bg-indigo-100 text-indigo-800',
endpoints: [
{ id: 'users-list', label: 'Listar Usuarios', method: 'GET', path: '/users/', description: 'Todos los usuarios del sistema' },
]
},
{
name: 'Tickets',
color: 'bg-orange-100 text-orange-800',
endpoints: [
{ id: 'tickets-list', label: 'Listar Tickets', method: 'GET', path: '/tickets/', description: 'Tickets con paginación' },
{ id: 'tickets-stats', label: 'Stats Tickets', method: 'GET', path: '/tickets/stats', description: 'Estadísticas de tickets' },
{ id: 'tickets-comments',label: 'Comentarios recientes', method: 'GET', path: '/tickets/comments/recent',description: 'Últimos comentarios' },
]
},
{
name: 'Categories',
color: 'bg-amber-100 text-amber-800',
endpoints: [
{ id: 'cats-list', label: 'Listar Categorías', method: 'GET', path: '/categories/', description: 'Categorías de tickets' },
]
},
{
name: 'Systems',
color: 'bg-gray-100 text-gray-800',
endpoints: [
{ id: 'sys-list', label: 'Listar Sistemas', method: 'GET', path: '/systems/', description: 'Sistemas soportados' },
]
},
{
name: 'SLA',
color: 'bg-teal-100 text-teal-800',
endpoints: [
{ id: 'sla-dashboard', label: 'Dashboard SLA', method: 'GET', path: '/sla/dashboard', description: 'Panel SLA principal' },
{ id: 'sla-compliance', label: 'SLA Compliance', method: 'GET', path: '/sla/compliance', description: 'Métricas de cumplimiento SLA' },
{ id: 'sla-at-risk', label: 'Tickets en Riesgo', method: 'GET', path: '/sla/at-risk', description: 'Tickets próximos a violar SLA' },
{ id: 'sla-violations', label: 'Violaciones SLA', method: 'GET', path: '/sla/violations', description: 'Tickets que violaron SLA' },
]
},
{
name: 'Reports',
color: 'bg-pink-100 text-pink-800',
endpoints: [
{ id: 'rep-summary', label: 'Resumen General', method: 'GET', path: '/reports/summary', description: 'Resumen ejecutivo de reportes' },
{ id: 'rep-agents', label: 'Por Agente', method: 'GET', path: '/reports/agents', description: 'Rendimiento por agente' },
{ id: 'rep-categories', label: 'Por Categoría', method: 'GET', path: '/reports/categories', description: 'Distribución por categoría' },
{ id: 'rep-trends', label: 'Tendencias', method: 'GET', path: '/reports/trends', description: 'Tendencias temporales' },
]
},
{
name: 'Audit',
color: 'bg-red-100 text-red-800',
endpoints: [
{ id: 'audit-logs', label: 'Logs de Auditoría', method: 'GET', path: '/audit/logs', description: 'Bitácora de acciones' },
{ id: 'audit-stats', label: 'Stats Auditoría', method: 'GET', path: '/audit/stats', description: 'Estadísticas de auditoría' },
{ id: 'audit-security', label: 'Análisis Seguridad', method: 'GET', path: '/audit/security/analysis',description: 'Análisis de amenazas de seguridad' },
{ id: 'audit-users', label: 'Actividad Usuarios', method: 'GET', path: '/audit/users', description: 'Actividad por usuario' },
]
},
{
name: 'Client Profile',
color: 'bg-cyan-100 text-cyan-800',
endpoints: [
{ id: 'client-profile', label: 'Perfil Cliente', method: 'GET', path: '/client/profile', description: 'Perfil organización cliente' },
{ id: 'client-tickets', label: 'Tickets Cliente', method: 'GET', path: '/client/tickets', description: 'Tickets del cliente' },
]
},
];
// ─── Frontend Pages ──────────────────────────────────────────────────────────
const FRONTEND_PAGES: PageDef[] = [
{ label: 'Dashboard', href: '/', description: 'Panel principal de administración', roles: ['todos'] },
{ label: 'Tickets', href: '/tickets', description: 'Gestión y listado de tickets', roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT'] },
{ label: 'Clientes (Tenants)', href: '/tenants', description: 'Administración de organizaciones cliente', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Usuarios', href: '/users', description: 'Gestión de usuarios internos', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Categorías', href: '/categories', description: 'Categorías y SLA por área', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Sistemas', href: '/systems', description: 'Catálogo de sistemas soportados', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'SLA Dashboard', href: '/sla', description: 'Monitoreo de SLAs y cumplimiento', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'SLA En Riesgo', href: '/sla/at-risk', description: 'Tickets próximos a violar SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'SLA Violaciones', href: '/sla/violations', description: 'Historial de violaciones SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Reportes', href: '/reports', description: 'Reportes estadísticos e informes', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Auditoría', href: '/audit', description: 'Bitácora de acciones del sistema', roles: ['ADMIN', 'AUDITOR'] },
{ label: 'Seguridad', href: '/audit/security', description: 'Análisis de amenazas y eventos de seguridad',roles: ['ADMIN'] },
{ label: 'Perfil', href: '/profile', description: 'Perfil y configuración de seguridad', roles: ['todos'] },
{ label: 'Rate Limits', href: '/rate-limits', description: 'Estado de rate limiting por IP', roles: ['ADMIN'] },
{ label: 'Reporte Endpoints', href: '/test-report', description: 'Esta misma página', roles: ['ADMIN'] },
];
// ─── State ───────────────────────────────────────────────────────────────────
let results: Record<string, EndpointResult> = {};
let isTesting = false;
let testingId: string | null = null;
let totalOk = 0;
let totalFail = 0;
let activeTab: 'endpoints' | 'pages' = 'endpoints';
// Init results
for (const group of GROUPS) {
for (const ep of group.endpoints) {
results[ep.id] = { status: null, ok: null, ms: null, error: null, preview: null, tested: false, loading: false };
}
}
function getToken(): string | null {
return (typeof window !== 'undefined')
? localStorage.getItem('internal_auth_token')
: null;
}
function getTenantId(): string | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('internal_auth_user');
if (stored) return JSON.parse(stored)?.tenant_id ?? null;
} catch { /* ignore */ }
return null;
}
async function testEndpoint(ep: EndpointDef) {
results[ep.id] = { ...results[ep.id], loading: true, tested: false };
results = results; // trigger reactivity
const token = getToken();
const tenantId = getTenantId();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
if (tenantId) headers['X-Tenant-ID'] = tenantId;
const url = `/api/v1${ep.path}`;
const t0 = performance.now();
try {
let fetchOptions: RequestInit = { method: ep.method, headers };
// For non-GET we don't send a body to avoid validation errors
const res = await fetch(url, fetchOptions);
const ms = Math.round(performance.now() - t0);
let preview: string | null = null;
try {
const text = await res.text();
const obj = JSON.parse(text);
preview = JSON.stringify(obj, null, 2).slice(0, 500);
if (JSON.stringify(obj, null, 2).length > 500) preview += '\n...';
} catch { /* ignore */ }
results[ep.id] = { status: res.status, ok: res.ok, ms, error: null, preview, tested: true, loading: false };
} catch (e: any) {
const ms = Math.round(performance.now() - t0);
results[ep.id] = { status: null, ok: false, ms, error: e.message ?? 'Network error', preview: null, tested: true, loading: false };
}
results = results;
recalcCounters();
}
async function testAll() {
isTesting = true;
totalOk = 0;
totalFail = 0;
for (const group of GROUPS) {
for (const ep of group.endpoints) {
testingId = ep.id;
await testEndpoint(ep);
}
}
testingId = null;
isTesting = false;
}
function recalcCounters() {
totalOk = Object.values(results).filter(r => r.tested && r.ok).length;
totalFail = Object.values(results).filter(r => r.tested && !r.ok).length;
}
function statusBadge(r: EndpointResult): { text: string; cls: string } {
if (r.loading) return { text: 'Probando...', cls: 'bg-gray-100 text-gray-600 animate-pulse' };
if (!r.tested) return { text: 'Sin probar', cls: 'bg-gray-100 text-gray-400' };
if (r.ok) return { text: `${r.status} OK`, cls: 'bg-green-100 text-green-700' };
return { text: r.status ? `${r.status} Error` : 'Fallo red', cls: 'bg-red-100 text-red-700' };
}
function methodBadge(method: string): string {
const map: Record<string, string> = {
GET: 'bg-blue-100 text-blue-700',
POST: 'bg-green-100 text-green-700',
PUT: 'bg-yellow-100 text-yellow-700',
DELETE: 'bg-red-100 text-red-700',
PATCH: 'bg-purple-100 text-purple-700',
};
return map[method] ?? 'bg-gray-100 text-gray-700';
}
let expandedIds = new Set<string>();
function toggleExpand(id: string) {
if (expandedIds.has(id)) expandedIds.delete(id);
else expandedIds.add(id);
expandedIds = new Set(expandedIds);
}
const testedCount = () => Object.values(results).filter(r => r.tested).length;
const totalEndpoints = GROUPS.reduce((acc, g) => acc + g.endpoints.length, 0);
</script>
<svelte:head>
<title>Reporte de Endpoints - ServiceManager</title>
</svelte:head>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="md:flex md:items-center md:justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900">Reporte de Endpoints & Páginas</h2>
<p class="mt-1 text-sm text-gray-500">
Diagnóstico de conectividad de todos los endpoints del backend y páginas del frontend.
</p>
</div>
<div class="mt-4 flex gap-2 md:mt-0">
<button
on:click={testAll}
disabled={isTesting}
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{#if isTesting}
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Probando endpoints...
{:else}
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Probar Todo
{/if}
</button>
</div>
</div>
<!-- Summary Bar -->
{#if testedCount() > 0}
<div class="mb-6 grid grid-cols-3 gap-4">
<div class="bg-white rounded-lg border p-4 text-center">
<div class="text-2xl font-bold text-gray-800">{testedCount()}/{totalEndpoints}</div>
<div class="text-xs text-gray-500 mt-1">Endpoints probados</div>
</div>
<div class="bg-green-50 rounded-lg border border-green-200 p-4 text-center">
<div class="text-2xl font-bold text-green-700">{totalOk}</div>
<div class="text-xs text-green-600 mt-1">OK / Exitosos</div>
</div>
<div class="bg-red-50 rounded-lg border border-red-200 p-4 text-center">
<div class="text-2xl font-bold text-red-700">{totalFail}</div>
<div class="text-xs text-red-600 mt-1">Errores / Fallidos</div>
</div>
</div>
{/if}
<!-- Tabs -->
<div class="flex border-b border-gray-200 mb-6">
<button
on:click={() => activeTab = 'endpoints'}
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'endpoints' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
>
Endpoints Backend ({totalEndpoints})
</button>
<button
on:click={() => activeTab = 'pages'}
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'pages' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
>
Páginas Frontend ({FRONTEND_PAGES.length})
</button>
</div>
<!-- ─── Endpoints Tab ─────────────────────────────────────────────────────── -->
{#if activeTab === 'endpoints'}
<div class="space-y-6">
{#each GROUPS as group}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<!-- Group header -->
<div class="flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-200">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full {group.color}">
{group.name}
</span>
<span class="text-xs text-gray-400">{group.endpoints.length} endpoint{group.endpoints.length !== 1 ? 's' : ''}</span>
</div>
<div class="flex gap-1 items-center">
{#each group.endpoints as ep}
{#if results[ep.id].tested}
<span class="w-2 h-2 rounded-full {results[ep.id].ok ? 'bg-green-400' : 'bg-red-400'}" title={ep.label}></span>
{/if}
{/each}
</div>
</div>
<!-- Endpoints list -->
<div class="divide-y divide-gray-100">
{#each group.endpoints as ep}
{@const r = results[ep.id]}
{@const badge = statusBadge(r)}
<div class="px-5 py-3">
<div class="flex items-center gap-3 flex-wrap">
<!-- Method badge -->
<span class="text-xs font-bold px-2 py-0.5 rounded font-mono {methodBadge(ep.method)}">
{ep.method}
</span>
<!-- Path -->
<code class="text-xs text-gray-700 bg-gray-50 px-2 py-0.5 rounded border border-gray-200 font-mono flex-shrink-0">
/api/v1{ep.path}
</code>
<!-- Label -->
<span class="text-sm text-gray-700 flex-1 min-w-0 truncate">{ep.label}</span>
<!-- Status + timing -->
<div class="flex items-center gap-2 ml-auto flex-shrink-0">
{#if r.ms !== null && r.tested}
<span class="text-xs text-gray-400">{r.ms}ms</span>
{/if}
<span class="text-xs font-medium px-2 py-0.5 rounded-full {badge.cls}">{badge.text}</span>
<!-- Test individual -->
<button
on:click={() => testEndpoint(ep)}
disabled={r.loading || isTesting}
class="ml-1 text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{r.loading ? '...' : 'Probar'}
</button>
<!-- Expand preview -->
{#if r.tested && r.preview}
<button
on:click={() => toggleExpand(ep.id)}
class="text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 transition-colors"
>
{expandedIds.has(ep.id) ? 'Ocultar' : 'Ver respuesta'}
</button>
{/if}
</div>
</div>
<!-- Description -->
<p class="mt-0.5 text-xs text-gray-400 ml-0.5">{ep.description}</p>
<!-- Error message -->
{#if r.tested && r.error}
<div class="mt-2 text-xs text-red-600 bg-red-50 rounded px-2 py-1.5 font-mono">{r.error}</div>
{/if}
<!-- Response preview -->
{#if expandedIds.has(ep.id) && r.preview}
<pre class="mt-2 text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-3 overflow-x-auto whitespace-pre-wrap break-words max-h-48">{r.preview}</pre>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
<!-- ─── Frontend Pages Tab ─────────────────────────────────────────────────── -->
{#if activeTab === 'pages'}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Página</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ruta</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Descripción</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Acción</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
{#each FRONTEND_PAGES as pg}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-3">
<span class="text-sm font-medium text-gray-900">{pg.label}</span>
</td>
<td class="px-6 py-3">
<code class="text-xs bg-gray-100 text-gray-700 px-2 py-0.5 rounded font-mono">{pg.href}</code>
</td>
<td class="px-6 py-3">
<span class="text-xs text-gray-500">{pg.description}</span>
</td>
<td class="px-6 py-3">
<div class="flex flex-wrap gap-1">
{#each pg.roles as role}
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">{role}</span>
{/each}
</div>
</td>
<td class="px-6 py-3">
<a
href={pg.href}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-blue-600 hover:text-blue-800 underline font-medium"
>
Abrir ↗
</a>
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Notes -->
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
<p class="text-xs text-gray-500">
<strong>Nota:</strong> Las páginas se abren en una nueva pestaña para verificar su renderizado.
Asegúrate de estar autenticado antes de acceder a rutas protegidas.
</p>
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{#each FRONTEND_PAGES as pg}
<a
href={pg.href}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 text-xs text-gray-700 hover:text-blue-700 transition-all group"
>
<svg class="w-3.5 h-3.5 text-gray-400 group-hover:text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span class="truncate font-medium">{pg.label}</span>
<code class="ml-auto text-gray-400 text-xs flex-shrink-0">{pg.href}</code>
</a>
{/each}
</div>
</div>
</div>
{/if}
</div>

View File

@@ -4,12 +4,23 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 3000,
// Puerto: dentro del contenedor siempre 3000; Docker mapea 3001:3000 al host
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor a través del puerto 3001 del host
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
@@ -19,10 +30,10 @@ export default defineConfig({
}
},
preview: {
port: 3000,
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
build: {
target: 'esnext'
}
});

View File

@@ -216,15 +216,18 @@ async def main():
if user_data["email"] in existing_emails:
print(f" ⏭ Ya existe: {user_data['email']}")
continue
pwd = user_data.pop("password")
# Usar copia para no mutar el dict original (permite re-ejecutar el script)
ud = user_data.copy()
pwd = ud.pop("password")
hashed_pwd = security.hash_password(pwd)
user = User(
tenant_id=tenant_id,
password_hash=hashed_pwd,
**user_data,
email_verified=True, # Marcar como verificado para permitir login
**ud,
)
session.add(user)
print(f"{user_data['email']} [{user_data['role'].value}] pwd={pwd}")
print(f"{ud['email']} [{ud['role'].value}] pwd={pwd}")
created_users += 1
await session.commit()