Initial commit
This commit is contained in:
110
.env.example
Normal file
110
.env.example
Normal file
@@ -0,0 +1,110 @@
|
||||
# ServiceManagerWeb - Variables de Entorno
|
||||
# Copia este archivo como .env y configura los valores apropiados
|
||||
|
||||
# ===================================
|
||||
# CONFIGURACIÓN GENERAL
|
||||
# ===================================
|
||||
ENVIRONMENT=development
|
||||
DEBUG=true
|
||||
SECRET_KEY=your-super-secret-key-change-in-production-min-32-chars
|
||||
API_VERSION=v1
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
|
||||
|
||||
# ===================================
|
||||
# BASE DE DATOS
|
||||
# ===================================
|
||||
# PostgreSQL
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=servicemanager
|
||||
POSTGRES_USER=servicemanager
|
||||
POSTGRES_PASSWORD=servicemanager123
|
||||
DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
|
||||
|
||||
# ===================================
|
||||
# REDIS Y CELERY
|
||||
# ===================================
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
|
||||
# ===================================
|
||||
# AUTENTICACIÓN JWT
|
||||
# ===================================
|
||||
JWT_SECRET_KEY=jwt-secret-key-change-in-production-min-32-chars
|
||||
JWT_ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# ===================================
|
||||
# EMAIL / NOTIFICACIONES
|
||||
# ===================================
|
||||
# SMTP Settings
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=false
|
||||
SMTP_USE_SSL=false
|
||||
|
||||
# Email por defecto
|
||||
DEFAULT_FROM_EMAIL=noreply@servicemanager.local
|
||||
DEFAULT_FROM_NAME=ServiceManager
|
||||
|
||||
# ===================================
|
||||
# ARCHIVOS Y STORAGE
|
||||
# ===================================
|
||||
# Configuración de uploads
|
||||
MAX_UPLOAD_SIZE_MB=10
|
||||
ALLOWED_FILE_EXTENSIONS=pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt,zip
|
||||
UPLOAD_PATH=/app/uploads
|
||||
|
||||
# ===================================
|
||||
# SEGURIDAD
|
||||
# ===================================
|
||||
# Rate limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_AUTH=10/minute
|
||||
RATE_LIMIT_API=100/minute
|
||||
RATE_LIMIT_UPLOAD=5/minute
|
||||
|
||||
# Password hashing
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
ARGON2_TIME_COST=3
|
||||
ARGON2_MEMORY_COST=65536
|
||||
ARGON2_PARALLELISM=4
|
||||
|
||||
# ===================================
|
||||
# LOGGING
|
||||
# ===================================
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FORMAT=json
|
||||
LOG_FILE=/app/logs/app.log
|
||||
|
||||
# ===================================
|
||||
# FRONTEND URLS
|
||||
# ===================================
|
||||
CLIENT_FRONTEND_URL=http://localhost:3000
|
||||
INTERNAL_FRONTEND_URL=http://localhost:3001
|
||||
API_BASE_URL=http://localhost:8000
|
||||
|
||||
# ===================================
|
||||
# HEALTH CHECKS
|
||||
# ===================================
|
||||
HEALTH_CHECK_TIMEOUT=30
|
||||
|
||||
# ===================================
|
||||
# DEVELOPMENT ONLY
|
||||
# ===================================
|
||||
# Adminer (DB admin interface)
|
||||
ADMINER_ENABLED=true
|
||||
|
||||
# MailHog (email testing)
|
||||
MAILHOG_ENABLED=true
|
||||
|
||||
# Seed data
|
||||
LOAD_SAMPLE_DATA=true
|
||||
101
.github/copilot-instructions.md
vendored
Normal file
101
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# ServiceManagerWeb - Mesa de Ayuda B2B Copilot Instructions
|
||||
|
||||
Este proyecto es un sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial.
|
||||
|
||||
## Contexto del Proyecto
|
||||
|
||||
- **Empresa**: Aduanasoft (B2B)
|
||||
- **Sistema**: Mesa de Ayuda multi-tenant
|
||||
- **Arquitectura**: Modular Monolith con Clean Architecture
|
||||
- **Target**: MVP enterprise-grade
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
### Backend
|
||||
- Python FastAPI (async)
|
||||
- Pydantic v2 para validación
|
||||
- SQLAlchemy 2.0 (async ORM)
|
||||
- PostgreSQL como base de datos principal
|
||||
- Redis para cache y broker Celery
|
||||
- Celery para tareas asíncronas
|
||||
- Alembic para migraciones
|
||||
- Argon2/Bcrypt para hash de passwords
|
||||
- PyJWT para autenticación
|
||||
|
||||
### Frontend
|
||||
- SvelteKit + TypeScript
|
||||
- Dos aplicaciones: cliente e interna
|
||||
- TailwindCSS para estilos
|
||||
- Zod para validación del lado cliente
|
||||
|
||||
### DevOps
|
||||
- Docker + Docker Compose
|
||||
- Nginx como reverse proxy
|
||||
- Variables de entorno para configuración
|
||||
- Healthchecks para servicios
|
||||
|
||||
## Dominios del Sistema
|
||||
|
||||
1. **auth**: Autenticación, usuarios, roles, 2FA
|
||||
2. **tenants**: Multi-tenancy, organizaciones cliente
|
||||
3. **tickets**: Core del sistema - tickets, estados, SLAs
|
||||
4. **notifications**: Email, plantillas, comunicaciones
|
||||
5. **audit**: Bitácora de acciones para compliance
|
||||
|
||||
## Roles de Usuario
|
||||
|
||||
- **ADMIN**: Control total (usuarios internos)
|
||||
- **SUPPORT_MANAGER**: Gestión equipos y SLAs
|
||||
- **AGENT**: Atención de tickets
|
||||
- **AUDITOR**: Solo lectura para auditoría
|
||||
- **CLIENT_ADMIN**: Gestión organización cliente
|
||||
- **CLIENT_USER**: Creación/seguimiento tickets
|
||||
|
||||
## Reglas de Desarrollo
|
||||
|
||||
### Seguridad
|
||||
- Siempre validar inputs con Pydantic
|
||||
- Rate limiting en endpoints críticos
|
||||
- Sanitizar archivos adjuntos
|
||||
- Correlation ID en logs
|
||||
- CORS restrictivo
|
||||
|
||||
### Código
|
||||
- Clean Architecture por dominios
|
||||
- Async/await en toda la aplicación
|
||||
- Type hints obligatorios
|
||||
- Docstrings en funciones públicas
|
||||
- Tests unitarios + integración
|
||||
|
||||
### Base de Datos
|
||||
- Migrations solo con Alembic
|
||||
- Constraints a nivel de BD
|
||||
- Índices para queries frecuentes
|
||||
- Soft deletes cuando aplique
|
||||
|
||||
### API
|
||||
- OpenAPI bien documentado
|
||||
- Versionado con prefijo /v1/
|
||||
- Paginación en listados
|
||||
- Responses consistentes
|
||||
|
||||
## Comandos Útiles
|
||||
|
||||
```bash
|
||||
# Setup inicial
|
||||
docker-compose up -d
|
||||
alembic upgrade head
|
||||
|
||||
# Desarrollo backend
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Testing
|
||||
pytest --cov=app tests/
|
||||
|
||||
# Linting
|
||||
ruff check . --fix
|
||||
black .
|
||||
mypy .
|
||||
```
|
||||
|
||||
Cuando trabajes en este proyecto, siempre considera la naturaleza multi-tenant y empresarial del sistema.
|
||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
venv/
|
||||
.venv/
|
||||
.env
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.svelte-kit/
|
||||
build/
|
||||
.npmrc
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## 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
|
||||
171
backend/README.md
Normal file
171
backend/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# ServiceManagerWeb Backend
|
||||
|
||||
FastAPI backend para el sistema de Mesa de Ayuda B2B multi-tenant.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI app principal
|
||||
│ ├── core/ # Configuración y utilidades core
|
||||
│ │ ├── config.py # Configuración con Pydantic Settings
|
||||
│ │ ├── database.py # SQLAlchemy async setup
|
||||
│ │ ├── security.py # JWT, hashing, 2FA
|
||||
│ │ └── logging.py # Structured logging
|
||||
│ ├── models/ # Modelos SQLAlchemy
|
||||
│ │ ├── tenant.py # Modelo de tenant (multi-tenancy)
|
||||
│ │ ├── user.py # Modelo de usuario
|
||||
│ │ └── ... # Otros modelos
|
||||
│ ├── api/ # API routes
|
||||
│ │ └── v1/ # API version 1
|
||||
│ │ ├── router.py # Router principal
|
||||
│ │ └── endpoints/ # Endpoints por dominio
|
||||
│ ├── middleware/ # Custom middleware
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── repositories/ # Data access layer
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ └── utils/ # Utilidades compartidas
|
||||
├── tests/ # Tests unitarios e integración
|
||||
├── migrations/ # Migraciones Alembic
|
||||
├── requirements.txt # Dependencias Python
|
||||
└── pyproject.toml # Configuración del proyecto
|
||||
```
|
||||
|
||||
## Características Implementadas
|
||||
|
||||
### Core
|
||||
- [x] FastAPI app con configuración async
|
||||
- [x] Pydantic Settings para configuración
|
||||
- [x] SQLAlchemy 2.0 async
|
||||
- [x] Structured logging con structlog
|
||||
- [x] JWT authentication con refresh tokens
|
||||
- [x] 2FA con TOTP
|
||||
- [x] Multi-tenancy middleware
|
||||
|
||||
### API
|
||||
- [x] Health checks (/health, /health/detailed)
|
||||
- [x] Authentication endpoints básicos
|
||||
- [x] Middleware de correlation ID y tenant
|
||||
- [x] Error handling centralizado
|
||||
- [x] CORS configurado
|
||||
|
||||
### Seguridad
|
||||
- [x] Argon2 password hashing
|
||||
- [x] JWT con algoritmos seguros
|
||||
- [x] TOTP 2FA implementation
|
||||
- [x] Validation con Pydantic v2
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Variables de entorno (copiar desde raíz del proyecto)
|
||||
cp ../.env.example .env
|
||||
|
||||
# Ejecutar en desarrollo
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# O usar Docker
|
||||
docker-compose up backend
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Ejecutar tests
|
||||
pytest
|
||||
|
||||
# Con coverage
|
||||
pytest --cov=app tests/
|
||||
|
||||
# Solo tests unitarios
|
||||
pytest -m "unit"
|
||||
|
||||
# Solo tests de integración
|
||||
pytest -m "integration"
|
||||
```
|
||||
|
||||
## Code Quality
|
||||
|
||||
```bash
|
||||
# Linting
|
||||
ruff check .
|
||||
|
||||
# Formateo
|
||||
black .
|
||||
|
||||
# Type checking
|
||||
mypy .
|
||||
|
||||
# Fix automático
|
||||
ruff check . --fix
|
||||
black .
|
||||
```
|
||||
|
||||
## Desarrollo
|
||||
|
||||
### Agregar nuevos endpoints
|
||||
|
||||
1. Crear schema en `app/schemas/`
|
||||
2. Crear endpoint en `app/api/v1/endpoints/`
|
||||
3. Registrar router en `app/api/v1/router.py`
|
||||
4. Agregar tests en `tests/`
|
||||
|
||||
### Modelos de base de datos
|
||||
|
||||
1. Crear modelo en `app/models/`
|
||||
2. Importar en `app/models/__init__.py`
|
||||
3. Crear migración: `alembic revision --autogenerate -m "descripción"`
|
||||
4. Aplicar migración: `alembic upgrade head`
|
||||
|
||||
### Variables de entorno
|
||||
|
||||
Todas las configuraciones están en `app/core/config.py` usando Pydantic Settings.
|
||||
|
||||
Ver `.env.example` para todas las variables disponibles.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
### Clean Architecture
|
||||
|
||||
- **Presentation**: FastAPI endpoints y schemas
|
||||
- **Application**: Services y casos de uso
|
||||
- **Domain**: Entidades y reglas de negocio
|
||||
- **Infrastructure**: Repositorios, DB, external APIs
|
||||
|
||||
### Patrones implementados
|
||||
|
||||
- Repository pattern para acceso a datos
|
||||
- Dependency injection con FastAPI Depends
|
||||
- Unit of Work para transacciones
|
||||
- Command/Query separation
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Structured logging con correlation IDs
|
||||
- Health checks para load balancer
|
||||
- Métricas con Prometheus (TODO)
|
||||
- Error tracking (TODO)
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [x] Password hashing con Argon2
|
||||
- [x] JWT con secret keys seguras
|
||||
- [x] CORS restrictivo
|
||||
- [x] Input validation con Pydantic
|
||||
- [x] SQL injection protection (SQLAlchemy)
|
||||
- [x] Rate limiting (TODO: implementar)
|
||||
- [x] File upload validation (TODO: implementar)
|
||||
- [x] XSS protection (headers en nginx)
|
||||
|
||||
## Próximos pasos
|
||||
|
||||
1. Implementar repositorios y services
|
||||
2. Completar autenticación con base de datos
|
||||
3. Agregar endpoints de users y tickets
|
||||
4. Implementar rate limiting
|
||||
5. Agregar métricas y monitoring
|
||||
6. Tests de integración completos
|
||||
22
backend/add_columns.py
Normal file
22
backend/add_columns.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from app.core.database import engine
|
||||
|
||||
async def add_columns():
|
||||
print("Starting schema update...")
|
||||
async with engine.begin() as conn:
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE tickets ADD COLUMN system_id UUID REFERENCES systems(id)"))
|
||||
print("Added system_id column")
|
||||
except Exception as e:
|
||||
print(f"Error adding system_id (might exist): {e}")
|
||||
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE tickets ADD COLUMN category_id UUID REFERENCES categories(id)"))
|
||||
print("Added category_id column")
|
||||
except Exception as e:
|
||||
print(f"Error adding category_id (might exist): {e}")
|
||||
print("Schema update finished.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(add_columns())
|
||||
57
backend/app/api/deps.py
Normal file
57
backend/app/api/deps.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import security
|
||||
from app.core.config import get_settings
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Define OAuth2 scheme here or import from auth if needed.
|
||||
# Defining here creates a separate instance which is fine as they share config.
|
||||
# Ideally auth.py should import from here, but modifying auth.py is risky now.
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
payload = security.verify_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
|
||||
return user
|
||||
|
||||
async def get_current_active_superuser(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="The user doesn't have enough privileges"
|
||||
)
|
||||
return current_user
|
||||
297
backend/app/api/v1/endpoints/auth.py
Normal file
297
backend/app/api/v1/endpoints/auth.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Authentication Endpoints - ServiceManagerWeb
|
||||
|
||||
Endpoints para autenticación y autorización
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import security
|
||||
from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
router = APIRouter()
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# OAuth2 scheme
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema for login request."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Schema for login response."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: dict
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema for refresh token request."""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema for token response."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
# ===================================
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Authenticate user and return access/refresh tokens.
|
||||
|
||||
Args:
|
||||
login_data: Login credentials
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
LoginResponse with tokens and user info
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
logger.info(
|
||||
"Login attempt",
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug
|
||||
)
|
||||
|
||||
# 1. Buscar usuario en base de datos
|
||||
query = select(User).where(User.email == login_data.email)
|
||||
result = await db.execute(query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 2. Verificar usuario y contraseña
|
||||
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||
logger.warning(
|
||||
"Login failed - invalid credentials",
|
||||
email=login_data.email
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenciales inválidas"
|
||||
)
|
||||
|
||||
# 3. Verificar si está activo
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Login failed - user inactive",
|
||||
email=login_data.email
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Usuario inactivo"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
"email": user.email,
|
||||
"role": user.role.value if hasattr(user.role, "value") else user.role,
|
||||
"tenant_id": str(user.tenant_id)
|
||||
}
|
||||
|
||||
access_token = security.create_access_token(token_data)
|
||||
refresh_token = security.create_refresh_token(token_data)
|
||||
|
||||
logger.info(
|
||||
"Login successful",
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug,
|
||||
user_id=str(user.id)
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
user={
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"role": user.role,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
"is_active": user.is_active,
|
||||
"is_two_factor_enabled": user.totp_enabled or False,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_token(
|
||||
refresh_data: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Refresh access token using refresh token.
|
||||
|
||||
Args:
|
||||
refresh_data: Refresh token data
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
New access token
|
||||
|
||||
Raises:
|
||||
HTTPException: If refresh token is invalid
|
||||
"""
|
||||
logger.info("Token refresh attempt")
|
||||
|
||||
# Verify refresh token
|
||||
payload = security.verify_token(refresh_data.refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
logger.warning("Token refresh failed - invalid token")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# TODO: Check if refresh token exists in database and is not revoked
|
||||
|
||||
# Create new access token
|
||||
token_data = {
|
||||
"sub": payload["sub"],
|
||||
"email": payload["email"],
|
||||
"role": payload["role"],
|
||||
"tenant_id": payload["tenant_id"]
|
||||
}
|
||||
|
||||
access_token = security.create_access_token(token_data)
|
||||
|
||||
logger.info("Token refresh successful", user_id=payload["sub"])
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Logout user and revoke refresh token.
|
||||
|
||||
Args:
|
||||
token: Access token
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
logger.info("Logout attempt")
|
||||
|
||||
# Verify token
|
||||
payload = security.verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# TODO: Revoke refresh token in database
|
||||
|
||||
logger.info("Logout successful", user_id=payload["sub"])
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get current user information.
|
||||
|
||||
Args:
|
||||
token: Access token
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Current user data
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid
|
||||
"""
|
||||
# Verify token
|
||||
payload = security.verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# TODO: Fetch actual user from database
|
||||
|
||||
return {
|
||||
"id": payload["sub"],
|
||||
"email": payload["email"],
|
||||
"role": payload["role"],
|
||||
"tenant_id": payload["tenant_id"]
|
||||
}
|
||||
|
||||
|
||||
# ===================================
|
||||
# DEPENDENCIES
|
||||
# ===================================
|
||||
|
||||
async def get_current_active_user(token: str = Depends(oauth2_scheme)):
|
||||
"""
|
||||
Dependency to get current active user from token.
|
||||
|
||||
Args:
|
||||
token: Access token
|
||||
|
||||
Returns:
|
||||
Current user data
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid or user is inactive
|
||||
"""
|
||||
payload = security.verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# TODO: Verify user exists and is active
|
||||
|
||||
return payload
|
||||
55
backend/app/api/v1/endpoints/categories.py
Normal file
55
backend/app/api/v1/endpoints/categories.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.category import Category
|
||||
from app.api import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool = True
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
class CategoryUpdate(CategoryBase):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
|
||||
class CategoryResponse(CategoryBase):
|
||||
id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@router.get("/", response_model=List[CategoryResponse])
|
||||
async def read_categories(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
query = select(Category).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/", response_model=CategoryResponse)
|
||||
async def create_category(
|
||||
category: CategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
db_category = Category(**category.model_dump())
|
||||
db.add(db_category)
|
||||
await db.commit()
|
||||
await db.refresh(db_category)
|
||||
return db_category
|
||||
88
backend/app/api/v1/endpoints/health.py
Normal file
88
backend/app/api/v1/endpoints/health.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Health Check Endpoints - ServiceManagerWeb
|
||||
|
||||
Endpoints para health checks y monitoring
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db, check_database_health
|
||||
from app.core.config import get_settings
|
||||
|
||||
router = APIRouter()
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""
|
||||
Basic health check endpoint.
|
||||
|
||||
Returns basic service information and status.
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "ServiceManagerWeb API",
|
||||
"version": settings.API_VERSION,
|
||||
"environment": settings.ENVIRONMENT
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/detailed")
|
||||
async def detailed_health_check(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Detailed health check with database connectivity.
|
||||
|
||||
Checks database connection and returns detailed status.
|
||||
"""
|
||||
# Check database
|
||||
db_healthy = await check_database_health()
|
||||
|
||||
# TODO: Add Redis health check
|
||||
# TODO: Add Celery health check
|
||||
|
||||
overall_status = "healthy" if db_healthy else "unhealthy"
|
||||
status_code = status.HTTP_200_OK if db_healthy else status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
health_data = {
|
||||
"status": overall_status,
|
||||
"service": "ServiceManagerWeb API",
|
||||
"version": settings.API_VERSION,
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"checks": {
|
||||
"database": "healthy" if db_healthy else "unhealthy",
|
||||
"redis": "not_implemented",
|
||||
"celery": "not_implemented"
|
||||
}
|
||||
}
|
||||
|
||||
if not db_healthy:
|
||||
logger.error("Health check failed - database unhealthy")
|
||||
|
||||
return health_data
|
||||
|
||||
|
||||
@router.get("/readiness")
|
||||
async def readiness_check():
|
||||
"""
|
||||
Kubernetes readiness probe endpoint.
|
||||
|
||||
Returns 200 if service is ready to accept traffic.
|
||||
"""
|
||||
# For now, just return ready
|
||||
# In production, this might check for startup completion,
|
||||
# database migrations, etc.
|
||||
return {"status": "ready"}
|
||||
|
||||
|
||||
@router.get("/liveness")
|
||||
async def liveness_check():
|
||||
"""
|
||||
Kubernetes liveness probe endpoint.
|
||||
|
||||
Returns 200 if service is alive and should not be restarted.
|
||||
"""
|
||||
return {"status": "alive"}
|
||||
53
backend/app/api/v1/endpoints/systems.py
Normal file
53
backend/app/api/v1/endpoints/systems.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.system import System
|
||||
from app.api import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class SystemBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
class SystemCreate(SystemBase):
|
||||
pass
|
||||
|
||||
class SystemUpdate(SystemBase):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class SystemResponse(SystemBase):
|
||||
id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@router.get("/", response_model=List[SystemResponse])
|
||||
async def read_systems(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
query = select(System).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/", response_model=SystemResponse)
|
||||
async def create_system(
|
||||
system: SystemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
db_system = System(**system.model_dump())
|
||||
db.add(db_system)
|
||||
await db.commit()
|
||||
await db.refresh(db_system)
|
||||
return db_system
|
||||
94
backend/app/api/v1/endpoints/tenants.py
Normal file
94
backend/app/api/v1/endpoints/tenants.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.api import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
pass
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
status: Optional[TenantStatus] = None
|
||||
|
||||
class TenantResponse(TenantBase):
|
||||
id: uuid.UUID
|
||||
status: TenantStatus
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@router.get("/", response_model=List[TenantResponse])
|
||||
async def read_tenants(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
query = select(Tenant).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/", response_model=TenantResponse)
|
||||
async def create_tenant(
|
||||
tenant: TenantCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
# Check existing slug
|
||||
query = select(Tenant).where(Tenant.slug == tenant.slug)
|
||||
result = await db.execute(query)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Tenant slug already exists")
|
||||
|
||||
db_tenant = Tenant(**tenant.model_dump())
|
||||
db.add(db_tenant)
|
||||
await db.commit()
|
||||
await db.refresh(db_tenant)
|
||||
return db_tenant
|
||||
|
||||
@router.get("/{tenant_id}", response_model=TenantResponse)
|
||||
async def read_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
tenant = await db.get(Tenant, tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
return tenant
|
||||
|
||||
@router.put("/{tenant_id}", response_model=TenantResponse)
|
||||
async def update_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
tenant_in: TenantUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
tenant = await db.get(Tenant, tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
update_data = tenant_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tenant, field, value)
|
||||
|
||||
db.add(tenant)
|
||||
await db.commit()
|
||||
await db.refresh(tenant)
|
||||
return tenant
|
||||
68
backend/app/api/v1/endpoints/users.py
Normal file
68
backend/app/api/v1/endpoints/users.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import security
|
||||
from app.models.user import User, UserRole
|
||||
from app.api import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
role: UserRole
|
||||
is_active: bool = True
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None # Optional password update
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def read_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
query = select(User).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/", response_model=UserResponse)
|
||||
async def create_user(
|
||||
user: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
query = select(User).where(User.email == user.email)
|
||||
result = await db.execute(query)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
user_data = user.model_dump(exclude={"password"})
|
||||
password_hash = security.get_password_hash(user.password)
|
||||
|
||||
db_user = User(**user_data, password_hash=password_hash)
|
||||
db.add(db_user)
|
||||
await db.commit()
|
||||
await db.refresh(db_user)
|
||||
return db_user
|
||||
47
backend/app/api/v1/router.py
Normal file
47
backend/app/api/v1/router.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
API v1 Router - ServiceManagerWeb
|
||||
|
||||
Router principal para la API v1
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Health check routes
|
||||
api_router.include_router(
|
||||
health.router,
|
||||
tags=["health"]
|
||||
)
|
||||
|
||||
# Authentication routes
|
||||
api_router.include_router(
|
||||
auth.router,
|
||||
prefix="/auth",
|
||||
tags=["authentication"]
|
||||
)
|
||||
|
||||
api_router.include_router(
|
||||
tenants.router,
|
||||
prefix="/tenants",
|
||||
tags=["tenants"]
|
||||
)
|
||||
|
||||
api_router.include_router(
|
||||
users.router,
|
||||
prefix="/users",
|
||||
tags=["users"]
|
||||
)
|
||||
|
||||
api_router.include_router(
|
||||
systems.router,
|
||||
prefix="/systems",
|
||||
tags=["systems"]
|
||||
)
|
||||
|
||||
api_router.include_router(
|
||||
categories.router,
|
||||
prefix="/categories",
|
||||
tags=["categories"]
|
||||
)
|
||||
142
backend/app/core/config.py
Normal file
142
backend/app/core/config.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Core Configuration - ServiceManagerWeb
|
||||
|
||||
Configuración centralizada usando Pydantic Settings v2
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuración de la aplicación."""
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"case_sensitive": False
|
||||
}
|
||||
|
||||
# ===================================
|
||||
# GENERAL
|
||||
# ===================================
|
||||
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT")
|
||||
DEBUG: bool = Field(default=False, env="DEBUG")
|
||||
SECRET_KEY: str = Field(..., env="SECRET_KEY")
|
||||
API_VERSION: str = Field(default="v1", env="API_VERSION")
|
||||
|
||||
# ===================================
|
||||
# DATABASE
|
||||
# ===================================
|
||||
DATABASE_URL: str = Field(..., env="DATABASE_URL")
|
||||
|
||||
# ===================================
|
||||
# REDIS
|
||||
# ===================================
|
||||
REDIS_URL: str = Field(..., env="REDIS_URL")
|
||||
|
||||
# ===================================
|
||||
# JWT AUTHENTICATION
|
||||
# ===================================
|
||||
JWT_SECRET_KEY: str = Field(..., env="JWT_SECRET_KEY")
|
||||
JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES")
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS")
|
||||
|
||||
# ===================================
|
||||
# CORS
|
||||
# ===================================
|
||||
CORS_ORIGINS: str = Field(
|
||||
default="http://localhost:3000,http://localhost:3001",
|
||||
env="CORS_ORIGINS"
|
||||
)
|
||||
|
||||
# ===================================
|
||||
# EMAIL
|
||||
# ===================================
|
||||
SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST")
|
||||
SMTP_PORT: int = Field(default=587, env="SMTP_PORT")
|
||||
SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER")
|
||||
SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD")
|
||||
SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS")
|
||||
SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL")
|
||||
|
||||
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL")
|
||||
DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME")
|
||||
|
||||
# ===================================
|
||||
# FILE UPLOADS
|
||||
# ===================================
|
||||
MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB")
|
||||
ALLOWED_FILE_EXTENSIONS: List[str] = Field(
|
||||
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"],
|
||||
env="ALLOWED_FILE_EXTENSIONS"
|
||||
)
|
||||
UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH")
|
||||
|
||||
@field_validator("ALLOWED_FILE_EXTENSIONS", mode='before')
|
||||
@classmethod
|
||||
def validate_file_extensions(cls, v):
|
||||
if isinstance(v, str):
|
||||
return [ext.strip().lower() for ext in v.split(",")]
|
||||
return [ext.lower() for ext in v]
|
||||
|
||||
# ===================================
|
||||
# SECURITY
|
||||
# ===================================
|
||||
RATE_LIMIT_ENABLED: bool = Field(default=True, env="RATE_LIMIT_ENABLED")
|
||||
PASSWORD_MIN_LENGTH: int = Field(default=8, env="PASSWORD_MIN_LENGTH")
|
||||
|
||||
# Argon2 settings
|
||||
ARGON2_TIME_COST: int = Field(default=3, env="ARGON2_TIME_COST")
|
||||
ARGON2_MEMORY_COST: int = Field(default=65536, env="ARGON2_MEMORY_COST")
|
||||
ARGON2_PARALLELISM: int = Field(default=4, env="ARGON2_PARALLELISM")
|
||||
|
||||
# ===================================
|
||||
# LOGGING
|
||||
# ===================================
|
||||
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT")
|
||||
LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE")
|
||||
|
||||
# ===================================
|
||||
# FRONTEND URLS
|
||||
# ===================================
|
||||
CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000", env="CLIENT_FRONTEND_URL")
|
||||
INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001", env="INTERNAL_FRONTEND_URL")
|
||||
|
||||
# ===================================
|
||||
# HEALTH CHECKS
|
||||
# ===================================
|
||||
HEALTH_CHECK_TIMEOUT: int = Field(default=30, env="HEALTH_CHECK_TIMEOUT")
|
||||
|
||||
# ===================================
|
||||
# CELERY
|
||||
# ===================================
|
||||
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
|
||||
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND")
|
||||
|
||||
def is_production(self) -> bool:
|
||||
"""Check if environment is production."""
|
||||
return self.ENVIRONMENT.lower() == "production"
|
||||
|
||||
def is_development(self) -> bool:
|
||||
"""Check if environment is development."""
|
||||
return self.ENVIRONMENT.lower() == "development"
|
||||
|
||||
def is_testing(self) -> bool:
|
||||
"""Check if environment is testing."""
|
||||
return self.ENVIRONMENT.lower() == "testing"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
Get cached settings instance.
|
||||
|
||||
Using lru_cache to create a singleton pattern for settings.
|
||||
"""
|
||||
return Settings()
|
||||
94
backend/app/core/database.py
Normal file
94
backend/app/core/database.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Database Configuration - ServiceManagerWeb
|
||||
|
||||
SQLAlchemy 2.0 async setup con PostgreSQL
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from typing import AsyncGenerator
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True, # Verify connections before use
|
||||
pool_recycle=3600, # Recycle connections after 1 hour
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class para todos los modelos SQLAlchemy."""
|
||||
|
||||
# Columnas comunes para auditoría
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency para obtener sesión de base de datos.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Sesión de base de datos
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def create_tables():
|
||||
"""Crear todas las tablas en desarrollo."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def drop_tables():
|
||||
"""Eliminar todas las tablas (solo para testing)."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
# Health check function
|
||||
async def check_database_health() -> bool:
|
||||
"""
|
||||
Verificar conectividad con la base de datos.
|
||||
|
||||
Returns:
|
||||
bool: True si la conexión es exitosa
|
||||
"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute("SELECT 1")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
145
backend/app/core/logging.py
Normal file
145
backend/app/core/logging.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Structured Logging Configuration - ServiceManagerWeb
|
||||
|
||||
Configuración de logging estructurado con structlog
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
from typing import Any, Dict
|
||||
import structlog
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def add_correlation_id(logger: Any, method_name: str, event_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Agregar correlation ID a los logs si está disponible."""
|
||||
# En un contexto de request real, esto vendría del middleware
|
||||
# Por ahora es un placeholder
|
||||
return event_dict
|
||||
|
||||
|
||||
def configure_structlog():
|
||||
"""Configurar structlog para logging estructurado."""
|
||||
|
||||
processors = [
|
||||
# Add the log level and a timestamp to the event_dict
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
add_correlation_id,
|
||||
]
|
||||
|
||||
if settings.LOG_FORMAT == "json":
|
||||
processors.append(structlog.processors.JSONRenderer())
|
||||
else:
|
||||
processors.append(structlog.dev.ConsoleRenderer())
|
||||
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
context_class=dict,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Configurar el sistema de logging completo."""
|
||||
|
||||
# Configure structlog
|
||||
configure_structlog()
|
||||
|
||||
# Configure standard library logging
|
||||
logging_config = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
"processor": structlog.processors.JSONRenderer(),
|
||||
},
|
||||
"console": {
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
"processor": structlog.dev.ConsoleRenderer(colors=True),
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": settings.LOG_LEVEL,
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": sys.stdout,
|
||||
"formatter": "json" if settings.LOG_FORMAT == "json" else "console",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"": { # root logger
|
||||
"handlers": ["console"],
|
||||
"level": settings.LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"sqlalchemy": {
|
||||
"handlers": ["console"],
|
||||
"level": "WARNING",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Add file handler if specified
|
||||
if settings.LOG_FILE:
|
||||
logging_config["handlers"]["file"] = {
|
||||
"level": settings.LOG_LEVEL,
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": settings.LOG_FILE,
|
||||
"maxBytes": 10 * 1024 * 1024, # 10MB
|
||||
"backupCount": 5,
|
||||
"formatter": "json",
|
||||
}
|
||||
|
||||
# Add file handler to all loggers
|
||||
for logger_config in logging_config["loggers"].values():
|
||||
logger_config["handlers"].append("file")
|
||||
|
||||
logging.config.dictConfig(logging_config)
|
||||
|
||||
|
||||
# Convenience function to get logger
|
||||
def get_logger(name: str = None) -> structlog.BoundLogger:
|
||||
"""
|
||||
Get a configured structlog logger.
|
||||
|
||||
Args:
|
||||
name: Logger name (optional)
|
||||
|
||||
Returns:
|
||||
Configured structlog logger
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
271
backend/app/core/security.py
Normal file
271
backend/app/core/security.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Security Utilities - ServiceManagerWeb
|
||||
|
||||
Funciones de seguridad para autenticación y autorización
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Union, Dict, Any
|
||||
from passlib.context import CryptContext
|
||||
from passlib.handlers.argon2 import argon2
|
||||
from jose import JWTError, jwt
|
||||
import pyotp
|
||||
import secrets
|
||||
import base64
|
||||
import struct
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Password hashing context
|
||||
pwd_context = CryptContext(
|
||||
schemes=["argon2"],
|
||||
deprecated="auto",
|
||||
argon2__time_cost=settings.ARGON2_TIME_COST,
|
||||
argon2__memory_cost=settings.ARGON2_MEMORY_COST,
|
||||
argon2__parallelism=settings.ARGON2_PARALLELISM,
|
||||
)
|
||||
|
||||
|
||||
class SecurityUtils:
|
||||
"""Utilidades de seguridad centralizadas."""
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Hash a password using Argon2.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Hashed password
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verify a password against its hash.
|
||||
|
||||
Args:
|
||||
plain_password: Plain text password
|
||||
hashed_password: Hashed password
|
||||
|
||||
Returns:
|
||||
True if password matches
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Create a JWT access token.
|
||||
|
||||
Args:
|
||||
data: Token payload
|
||||
expires_delta: Token expiration time
|
||||
|
||||
Returns:
|
||||
JWT token string
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithm=settings.JWT_ALGORITHM
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
@staticmethod
|
||||
def create_refresh_token(data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Create a JWT refresh token.
|
||||
|
||||
Args:
|
||||
data: Token payload
|
||||
|
||||
Returns:
|
||||
JWT refresh token string
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithm=settings.JWT_ALGORITHM
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
@staticmethod
|
||||
def verify_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Verify and decode a JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Token payload if valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM]
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def generate_totp_secret() -> str:
|
||||
"""
|
||||
Generate a base32-encoded secret for TOTP.
|
||||
|
||||
Returns:
|
||||
Base32 encoded secret
|
||||
"""
|
||||
return pyotp.random_base32()
|
||||
|
||||
@staticmethod
|
||||
def generate_totp_uri(secret: str, email: str, issuer_name: str = "ServiceManager") -> str:
|
||||
"""
|
||||
Generate TOTP URI for QR code.
|
||||
|
||||
Args:
|
||||
secret: Base32 encoded secret
|
||||
email: User email
|
||||
issuer_name: Application name
|
||||
|
||||
Returns:
|
||||
TOTP URI
|
||||
"""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.provisioning_uri(
|
||||
name=email,
|
||||
issuer_name=issuer_name
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_totp(secret: str, token: str, window: int = 1) -> bool:
|
||||
"""
|
||||
Verify a TOTP token.
|
||||
|
||||
Args:
|
||||
secret: Base32 encoded secret
|
||||
token: TOTP token
|
||||
window: Time window tolerance
|
||||
|
||||
Returns:
|
||||
True if token is valid
|
||||
"""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(token, valid_window=window)
|
||||
|
||||
@staticmethod
|
||||
def generate_backup_codes(count: int = 8) -> list[str]:
|
||||
"""
|
||||
Generate backup codes for 2FA.
|
||||
|
||||
Args:
|
||||
count: Number of codes to generate
|
||||
|
||||
Returns:
|
||||
List of backup codes
|
||||
"""
|
||||
codes = []
|
||||
for _ in range(count):
|
||||
code = secrets.token_hex(4).upper()
|
||||
# Format as XXXX-XXXX
|
||||
formatted_code = f"{code[:4]}-{code[4:]}"
|
||||
codes.append(formatted_code)
|
||||
return codes
|
||||
|
||||
@staticmethod
|
||||
def hash_token(token: str) -> str:
|
||||
"""
|
||||
Hash a token for secure storage.
|
||||
|
||||
Args:
|
||||
token: Token to hash
|
||||
|
||||
Returns:
|
||||
Hashed token
|
||||
"""
|
||||
return pwd_context.hash(token)
|
||||
|
||||
@staticmethod
|
||||
def verify_hashed_token(token: str, hashed_token: str) -> bool:
|
||||
"""
|
||||
Verify a token against its hash.
|
||||
|
||||
Args:
|
||||
token: Plain token
|
||||
hashed_token: Hashed token
|
||||
|
||||
Returns:
|
||||
True if token matches
|
||||
"""
|
||||
return pwd_context.verify(token, hashed_token)
|
||||
|
||||
@staticmethod
|
||||
def generate_secure_token(length: int = 32) -> str:
|
||||
"""
|
||||
Generate a cryptographically secure random token.
|
||||
|
||||
Args:
|
||||
length: Token length in bytes
|
||||
|
||||
Returns:
|
||||
URL-safe base64 encoded token
|
||||
"""
|
||||
token = secrets.token_bytes(length)
|
||||
return base64.urlsafe_b64encode(token).decode('utf-8').rstrip('=')
|
||||
|
||||
@staticmethod
|
||||
def is_strong_password(password: str) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Check if password meets security requirements.
|
||||
|
||||
Args:
|
||||
password: Password to check
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_issues)
|
||||
"""
|
||||
issues = []
|
||||
|
||||
if len(password) < settings.PASSWORD_MIN_LENGTH:
|
||||
issues.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long")
|
||||
|
||||
if not any(c.islower() for c in password):
|
||||
issues.append("Password must contain at least one lowercase letter")
|
||||
|
||||
if not any(c.isupper() for c in password):
|
||||
issues.append("Password must contain at least one uppercase letter")
|
||||
|
||||
if not any(c.isdigit() for c in password):
|
||||
issues.append("Password must contain at least one digit")
|
||||
|
||||
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
|
||||
issues.append("Password must contain at least one special character")
|
||||
|
||||
return len(issues) == 0, issues
|
||||
|
||||
|
||||
# Create singleton instance
|
||||
security = SecurityUtils()
|
||||
201
backend/app/main.py
Normal file
201
backend/app/main.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
ServiceManagerWeb Backend - FastAPI Application
|
||||
|
||||
Mesa de Ayuda B2B multi-tenant con Clean Architecture
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import structlog
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import engine, create_tables
|
||||
# Import models to register them with SQLAlchemy
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.system import System
|
||||
from app.models.category import Category
|
||||
from app.models.user import User
|
||||
from app.models.ticket import Ticket
|
||||
|
||||
from app.core.logging import setup_logging
|
||||
from app.api.v1.router import api_router
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
from app.middleware.correlation_id import CorrelationIDMiddleware
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging()
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifecycle manager para la aplicación."""
|
||||
# Startup
|
||||
logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION)
|
||||
|
||||
if settings.ENVIRONMENT == "development":
|
||||
await create_tables()
|
||||
logger.info("Tablas de base de datos verificadas")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Cerrando ServiceManagerWeb Backend")
|
||||
|
||||
|
||||
# Crear aplicación FastAPI
|
||||
app = FastAPI(
|
||||
title="ServiceManagerWeb API",
|
||||
description="Mesa de Ayuda B2B multi-tenant para Aduanasoft",
|
||||
version=settings.API_VERSION,
|
||||
lifespan=lifespan,
|
||||
docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None,
|
||||
redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None,
|
||||
openapi_url=f"/{settings.API_VERSION}/openapi.json"
|
||||
)
|
||||
|
||||
# ===================================
|
||||
# MIDDLEWARE
|
||||
# ===================================
|
||||
|
||||
# CORS
|
||||
cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Compression
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# Custom middleware
|
||||
app.add_middleware(CorrelationIDMiddleware)
|
||||
app.add_middleware(TenantMiddleware)
|
||||
|
||||
# Request logging middleware
|
||||
@app.middleware("http")
|
||||
async def request_logging_middleware(request: Request, call_next):
|
||||
"""Log todas las requests con métricas de performance."""
|
||||
start_time = time.time()
|
||||
correlation_id = getattr(request.state, "correlation_id", str(uuid.uuid4()))
|
||||
|
||||
# Log request
|
||||
logger.info(
|
||||
"Request iniciada",
|
||||
method=request.method,
|
||||
url=str(request.url),
|
||||
correlation_id=correlation_id,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
remote_addr=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Log response
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
"Request completada",
|
||||
method=request.method,
|
||||
url=str(request.url),
|
||||
status_code=response.status_code,
|
||||
duration=f"{duration:.3f}s",
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
# Add correlation ID to response headers
|
||||
response.headers["X-Correlation-ID"] = correlation_id
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ===================================
|
||||
# EXCEPTION HANDLERS
|
||||
# ===================================
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""Handler global para excepciones no capturadas."""
|
||||
correlation_id = getattr(request.state, "correlation_id", str(uuid.uuid4()))
|
||||
|
||||
logger.error(
|
||||
"Excepción no manejada",
|
||||
error=str(exc),
|
||||
correlation_id=correlation_id,
|
||||
url=str(request.url),
|
||||
method=request.method,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "Error interno del servidor"
|
||||
},
|
||||
"correlation_id": correlation_id
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# ROUTES
|
||||
# ===================================
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check para load balancer y monitoring."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "ServiceManagerWeb API",
|
||||
"version": settings.API_VERSION,
|
||||
"environment": settings.ENVIRONMENT
|
||||
}
|
||||
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Endpoint raíz con información básica."""
|
||||
return {
|
||||
"service": "ServiceManagerWeb API",
|
||||
"version": settings.API_VERSION,
|
||||
"docs": f"/{settings.API_VERSION}/docs",
|
||||
"environment": settings.ENVIRONMENT
|
||||
}
|
||||
|
||||
|
||||
# API routes
|
||||
app.include_router(
|
||||
api_router,
|
||||
prefix=f"/{settings.API_VERSION}",
|
||||
responses={
|
||||
400: {"description": "Bad Request"},
|
||||
401: {"description": "Unauthorized"},
|
||||
403: {"description": "Forbidden"},
|
||||
404: {"description": "Not Found"},
|
||||
422: {"description": "Validation Error"},
|
||||
500: {"description": "Internal Server Error"}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.ENVIRONMENT == "development"
|
||||
)
|
||||
46
backend/app/middleware/correlation_id.py
Normal file
46
backend/app/middleware/correlation_id.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Correlation ID Middleware - ServiceManagerWeb
|
||||
|
||||
Middleware para rastrear requests con correlation ID
|
||||
"""
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
import uuid
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CorrelationIDMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware para manejar correlation IDs.
|
||||
|
||||
Extrae el correlation ID del header X-Correlation-ID o genera uno nuevo.
|
||||
Lo almacena en el estado de la request para uso en logs y responses.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
"""Process request and add correlation ID."""
|
||||
|
||||
# Extract or generate correlation ID
|
||||
correlation_id = request.headers.get("X-Correlation-ID")
|
||||
if not correlation_id:
|
||||
correlation_id = str(uuid.uuid4())
|
||||
|
||||
# Store in request state
|
||||
request.state.correlation_id = correlation_id
|
||||
|
||||
# Add to structlog context
|
||||
with structlog.contextvars.bound_contextvars(
|
||||
correlation_id=correlation_id,
|
||||
path=request.url.path,
|
||||
method=request.method
|
||||
):
|
||||
response = await call_next(request)
|
||||
|
||||
# Add correlation ID to response headers
|
||||
response.headers["X-Correlation-ID"] = correlation_id
|
||||
|
||||
return response
|
||||
72
backend/app/middleware/tenant.py
Normal file
72
backend/app/middleware/tenant.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Tenant Middleware - ServiceManagerWeb
|
||||
|
||||
Middleware para manejo de multi-tenancy
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware para extraer y validar información del tenant.
|
||||
|
||||
Extrae el tenant_id del header X-Tenant-ID y lo almacena
|
||||
en el estado de la request para uso posterior.
|
||||
"""
|
||||
|
||||
# Rutas que no requieren tenant
|
||||
EXCLUDED_PATHS = {
|
||||
"/health",
|
||||
"/",
|
||||
"/v1/auth/login",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc"
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
"""Process request and add tenant information."""
|
||||
|
||||
# Skip tenant validation for excluded paths
|
||||
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract tenant from header
|
||||
tenant_id = request.headers.get("X-Tenant-ID")
|
||||
tenant_slug = request.headers.get("X-Tenant-Slug")
|
||||
|
||||
# For now, we'll be more permissive in development
|
||||
# In production, tenant should be strictly required
|
||||
if not tenant_id and not tenant_slug:
|
||||
logger.warning(
|
||||
"Request without tenant information",
|
||||
path=request.url.path,
|
||||
method=request.method
|
||||
)
|
||||
# For now, continue without tenant for development
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
||||
# detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
|
||||
# )
|
||||
|
||||
# Store tenant info in request state
|
||||
request.state.tenant_id = tenant_id
|
||||
request.state.tenant_slug = tenant_slug
|
||||
|
||||
# TODO: Validate tenant exists and is active
|
||||
# This would involve a database query which we'll implement later
|
||||
|
||||
logger.debug(
|
||||
"Tenant middleware processed",
|
||||
tenant_id=tenant_id,
|
||||
tenant_slug=tenant_slug,
|
||||
path=request.url.path
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
28
backend/app/models/category.py
Normal file
28
backend/app/models/category.py
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
"""
|
||||
Category Model - ServiceManagerWeb
|
||||
"""
|
||||
from sqlalchemy import String, Text, Boolean, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Optional: Tenant specific categories?
|
||||
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category")
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Category(id={self.id}, name='{self.name}')>"
|
||||
25
backend/app/models/system.py
Normal file
25
backend/app/models/system.py
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
"""
|
||||
System Model - ServiceManagerWeb
|
||||
"""
|
||||
from sqlalchemy import String, Text, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
class System(Base):
|
||||
__tablename__ = "systems"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
# If we want tickets to link to systems, we will add relationship in Ticket later or now.
|
||||
# We will assume Ticket links to System.
|
||||
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="system")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<System(id={self.id}, name='{self.name}')>"
|
||||
68
backend/app/models/tenant.py
Normal file
68
backend/app/models/tenant.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Tenant Model - ServiceManagerWeb
|
||||
|
||||
Modelo para organizaciones cliente (multi-tenancy)
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Integer, Text, Boolean, ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from typing import List, Optional
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TenantStatus(str, enum.Enum):
|
||||
"""Estados de un tenant."""
|
||||
ACTIVE = "active"
|
||||
SUSPENDED = "suspended"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""Modelo de Tenant (Organización cliente)."""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
# Información básica
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
domain: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
logo_url: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
|
||||
# Contacto
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(320))
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
address: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
# Configuración regional
|
||||
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
|
||||
locale: Mapped[str] = mapped_column(String(10), default="es-ES")
|
||||
|
||||
# Límites y configuración
|
||||
max_users: Mapped[int] = mapped_column(Integer, default=50)
|
||||
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
|
||||
allowed_file_types: Mapped[List[str]] = mapped_column(
|
||||
ARRAY(String),
|
||||
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
|
||||
)
|
||||
|
||||
# Estado
|
||||
status: Mapped[TenantStatus] = mapped_column(
|
||||
String(20),
|
||||
default=TenantStatus.ACTIVE
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
users: Mapped[List["User"]] = relationship("User", back_populates="tenant")
|
||||
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if tenant is active."""
|
||||
return self.status == TenantStatus.ACTIVE
|
||||
65
backend/app/models/ticket.py
Normal file
65
backend/app/models/ticket.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Ticket Model - ServiceManagerWeb
|
||||
"""
|
||||
from sqlalchemy import String, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from typing import Optional
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
class TicketStatus(str, enum.Enum):
|
||||
NEW = "NEW"
|
||||
TRIAGE = "TRIAGE"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
WAITING_FOR_CLIENT = "WAITING_FOR_CLIENT"
|
||||
RESOLVED = "RESOLVED"
|
||||
CLOSED = "CLOSED"
|
||||
REOPENED = "REOPENED"
|
||||
|
||||
class TicketPriority(str, enum.Enum):
|
||||
LOW = "LOW"
|
||||
MEDIUM = "MEDIUM"
|
||||
HIGH = "HIGH"
|
||||
URGENT = "URGENT"
|
||||
|
||||
class Ticket(Base):
|
||||
__tablename__ = "tickets"
|
||||
|
||||
# Note: id, created_at, updated_at are inherited from Base
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
status: Mapped[TicketStatus] = mapped_column(ENUM(TicketStatus, name="ticket_status_enum", create_type=False), default=TicketStatus.NEW)
|
||||
priority: Mapped[TicketPriority] = mapped_column(ENUM(TicketPriority, name="ticket_priority_enum", create_type=False), default=TicketPriority.MEDIUM)
|
||||
|
||||
# Foreign Keys
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True)
|
||||
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets")
|
||||
|
||||
system: Mapped["System"] = relationship("System", back_populates="tickets")
|
||||
category: Mapped["Category"] = relationship("Category", back_populates="tickets")
|
||||
|
||||
created_by_user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[created_by],
|
||||
back_populates="created_tickets"
|
||||
)
|
||||
|
||||
assigned_to_user: Mapped[Optional["User"]] = relationship(
|
||||
"User",
|
||||
foreign_keys=[assigned_to],
|
||||
back_populates="assigned_tickets"
|
||||
)
|
||||
135
backend/app/models/user.py
Normal file
135
backend/app/models/user.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
User Model - ServiceManagerWeb
|
||||
|
||||
Modelo para usuarios del sistema (internos y clientes)
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from typing import Optional, List
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""Roles de usuario en el sistema."""
|
||||
# Staff interno
|
||||
ADMIN = "ADMIN" # Control total
|
||||
SUPPORT_MANAGER = "SUPPORT_MANAGER" # Gestión de equipos y SLAs
|
||||
AGENT = "AGENT" # Atención de tickets
|
||||
AUDITOR = "AUDITOR" # Solo lectura para auditoría
|
||||
|
||||
# Clientes
|
||||
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
|
||||
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Modelo de Usuario."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
# Relación con tenant
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Información básica
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
|
||||
# Autenticación
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(ENUM(UserRole), nullable=False)
|
||||
|
||||
# 2FA (opcional para staff interno)
|
||||
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String))
|
||||
|
||||
# Estado
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
last_activity: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Preferencias
|
||||
language: Mapped[str] = mapped_column(String(10), default="es")
|
||||
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
|
||||
notifications_email: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Relaciones
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users")
|
||||
created_tickets: Mapped[List["Ticket"]] = relationship(
|
||||
"Ticket",
|
||||
back_populates="created_by_user",
|
||||
foreign_keys="Ticket.created_by"
|
||||
)
|
||||
assigned_tickets: Mapped[List["Ticket"]] = relationship(
|
||||
"Ticket",
|
||||
back_populates="assigned_to_user",
|
||||
foreign_keys="Ticket.assigned_to"
|
||||
)
|
||||
|
||||
# Unique constraint por tenant
|
||||
__table_args__ = (
|
||||
{"postgresql_tablespace": "users"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
"""Get user's full name."""
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
@property
|
||||
def is_staff(self) -> bool:
|
||||
"""Check if user is internal staff."""
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT,
|
||||
UserRole.AUDITOR
|
||||
]
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""Check if user is a client."""
|
||||
return self.role in [
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER
|
||||
]
|
||||
|
||||
@property
|
||||
def can_manage_users(self) -> bool:
|
||||
"""Check if user can manage other users."""
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.CLIENT_ADMIN
|
||||
]
|
||||
|
||||
@property
|
||||
def can_manage_tickets(self) -> bool:
|
||||
"""Check if user can manage tickets."""
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT
|
||||
]
|
||||
|
||||
@property
|
||||
def requires_2fa(self) -> bool:
|
||||
"""Check if 2FA is required for this user."""
|
||||
# 2FA opcional para staff interno, no requerido para clientes
|
||||
return self.is_staff
|
||||
41
backend/fix_password_script.py
Normal file
41
backend/fix_password_script.py
Normal file
@@ -0,0 +1,41 @@
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path so we can import 'app'
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.tenant import Tenant # Import Tenant to register it
|
||||
from app.models.ticket import Ticket # Import Ticket to register it
|
||||
from app.models.user import User
|
||||
from app.core.security import SecurityUtils
|
||||
|
||||
async def fix_password():
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Find the admin user
|
||||
email = "admin@aduanasoft.com"
|
||||
result = await session.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
print(f"User {email} found.")
|
||||
# Reset password to 'admin123'
|
||||
new_password = "admin123"
|
||||
hashed = SecurityUtils.hash_password(new_password)
|
||||
user.password_hash = hashed
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
print(f"Password for {email} updated successfully!")
|
||||
print(f"New password is: {new_password}")
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
print(f"Error updating password: {e}")
|
||||
else:
|
||||
print(f"User {email} not found!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fix_password())
|
||||
181
backend/pyproject.toml
Normal file
181
backend/pyproject.toml
Normal file
@@ -0,0 +1,181 @@
|
||||
# ServiceManagerWeb Backend - PyProject Configuration
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "servicemanager-backend"
|
||||
version = "0.1.0"
|
||||
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
|
||||
authors = [
|
||||
{name = "Aduanasoft", email = "dev@aduanasoft.com"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "Proprietary"}
|
||||
|
||||
dependencies = [
|
||||
"fastapi==0.104.1",
|
||||
"uvicorn[standard]==0.24.0",
|
||||
"pydantic==2.5.0",
|
||||
"pydantic-settings==2.1.0",
|
||||
"sqlalchemy==2.0.23",
|
||||
"alembic==1.13.0",
|
||||
"asyncpg==0.29.0",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"python-jose[cryptography]==3.3.0",
|
||||
"python-multipart==0.0.6",
|
||||
"passlib[argon2]==1.7.4",
|
||||
"argon2-cffi==23.1.0",
|
||||
"pyotp==2.9.0",
|
||||
"celery==5.3.4",
|
||||
"redis==5.0.1",
|
||||
"email-validator==2.1.0",
|
||||
"jinja2==3.1.2",
|
||||
"python-magic==0.4.27",
|
||||
"pillow==10.1.0",
|
||||
"httpx==0.25.2",
|
||||
"aiofiles==23.2.1",
|
||||
"python-dateutil==2.8.2",
|
||||
"pytz==2023.3",
|
||||
"slugify==0.0.1",
|
||||
"structlog==23.2.0",
|
||||
"prometheus-client==0.19.0"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==7.4.3",
|
||||
"pytest-asyncio==0.21.1",
|
||||
"pytest-cov==4.1.0",
|
||||
"faker==20.1.0",
|
||||
"ruff==0.1.7",
|
||||
"black==23.11.0",
|
||||
"mypy==1.7.1",
|
||||
"pre-commit==3.6.0"
|
||||
]
|
||||
|
||||
production = [
|
||||
"gunicorn==21.2.0"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
|
||||
# ===================================
|
||||
# RUFF CONFIGURATION
|
||||
# ===================================
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
"N", # pep8-naming
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long, handled by black
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"C901", # too complex
|
||||
]
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"__init__.py" = ["F401"]
|
||||
"tests/**/*" = ["N802", "N803", "N806"]
|
||||
|
||||
[tool.ruff.isort]
|
||||
known-first-party = ["app"]
|
||||
|
||||
# ===================================
|
||||
# BLACK CONFIGURATION
|
||||
# ===================================
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py311']
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
# directories
|
||||
\.eggs
|
||||
| \.git
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
'''
|
||||
|
||||
# ===================================
|
||||
# MYPY CONFIGURATION
|
||||
# ===================================
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
check_untyped_defs = true
|
||||
disallow_any_generics = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_return_any = true
|
||||
strict_equality = true
|
||||
show_error_codes = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"passlib.*",
|
||||
"pyotp.*",
|
||||
"magic.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
# ===================================
|
||||
# PYTEST CONFIGURATION
|
||||
# ===================================
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "7.0"
|
||||
addopts = "-ra -q --strict-markers --strict-config"
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
markers = [
|
||||
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"integration: marks tests as integration tests",
|
||||
"unit: marks tests as unit tests",
|
||||
]
|
||||
|
||||
# ===================================
|
||||
# COVERAGE CONFIGURATION
|
||||
# ===================================
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/venv/*",
|
||||
"*/__pycache__/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if self.debug:",
|
||||
"if settings.DEBUG",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if 0:",
|
||||
"if __name__ == .__main__.:",
|
||||
"class .*\\bProtocol\\):",
|
||||
"@(abc\\.)?abstractmethod",
|
||||
]
|
||||
86
backend/requirements.txt
Normal file
86
backend/requirements.txt
Normal file
@@ -0,0 +1,86 @@
|
||||
# Backend Requirements - ServiceManagerWeb
|
||||
# FastAPI Backend Dependencies
|
||||
|
||||
# ===================================
|
||||
# CORE FRAMEWORK
|
||||
# ===================================
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# ===================================
|
||||
# DATABASE & ORM
|
||||
# ===================================
|
||||
sqlalchemy==2.0.23
|
||||
alembic==1.13.0
|
||||
asyncpg==0.29.0 # PostgreSQL async driver
|
||||
psycopg2-binary==2.9.9 # PostgreSQL sync driver (for migrations)
|
||||
|
||||
# ===================================
|
||||
# AUTHENTICATION & SECURITY
|
||||
# ===================================
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-multipart==0.0.6
|
||||
passlib[argon2]==1.7.4
|
||||
argon2-cffi==23.1.0
|
||||
pyotp==2.9.0 # TOTP/2FA support
|
||||
|
||||
# ===================================
|
||||
# ASYNC TASKS
|
||||
# ===================================
|
||||
celery==5.3.4
|
||||
redis==5.0.1
|
||||
|
||||
# ===================================
|
||||
# EMAIL
|
||||
# ===================================
|
||||
email-validator==2.1.0
|
||||
jinja2==3.1.2 # Email templates
|
||||
|
||||
# ===================================
|
||||
# FILE HANDLING
|
||||
# ===================================
|
||||
python-magic==0.4.27 # MIME type detection
|
||||
pillow==10.1.0 # Image processing
|
||||
|
||||
# ===================================
|
||||
# HTTP & REQUESTS
|
||||
# ===================================
|
||||
httpx==0.25.2 # Async HTTP client
|
||||
aiofiles==23.2.1 # Async file operations
|
||||
|
||||
# ===================================
|
||||
# UTILITIES
|
||||
# ===================================
|
||||
python-dateutil==2.8.2
|
||||
pytz==2023.3
|
||||
slugify==0.0.1
|
||||
|
||||
# ===================================
|
||||
# MONITORING & LOGGING
|
||||
# ===================================
|
||||
structlog==23.2.0
|
||||
prometheus-client==0.19.0
|
||||
|
||||
# ===================================
|
||||
# DEVELOPMENT & TESTING
|
||||
# ===================================
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
httpx==0.25.2 # For testing
|
||||
faker==20.1.0 # Test data generation
|
||||
|
||||
# ===================================
|
||||
# CODE QUALITY
|
||||
# ===================================
|
||||
ruff==0.1.7 # Linter & formatter
|
||||
black==23.11.0 # Code formatter
|
||||
mypy==1.7.1 # Type checker
|
||||
pre-commit==3.6.0 # Git hooks
|
||||
|
||||
# ===================================
|
||||
# PRODUCTION
|
||||
# ===================================
|
||||
gunicorn==21.2.0
|
||||
500
db/schema.sql
Normal file
500
db/schema.sql
Normal file
@@ -0,0 +1,500 @@
|
||||
-- ServiceManagerWeb - Esquema de Base de Datos
|
||||
-- Sistema multi-tenant de Mesa de Ayuda B2B
|
||||
-- PostgreSQL 15+
|
||||
|
||||
-- ===================================
|
||||
-- DOMINIO: TENANTS (Multi-tenancy)
|
||||
-- ===================================
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
domain VARCHAR(255),
|
||||
logo_url VARCHAR(500),
|
||||
contact_email VARCHAR(320),
|
||||
contact_phone VARCHAR(20),
|
||||
address TEXT,
|
||||
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||
locale VARCHAR(10) DEFAULT 'es-ES',
|
||||
|
||||
-- Settings
|
||||
max_users INTEGER DEFAULT 50,
|
||||
max_storage_mb INTEGER DEFAULT 1024,
|
||||
allowed_file_types TEXT[] DEFAULT ARRAY['pdf','jpg','jpeg','png','doc','docx','xls','xlsx','txt'],
|
||||
|
||||
-- Status
|
||||
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'inactive')),
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT tenants_slug_format CHECK (slug ~ '^[a-z0-9-]+$')
|
||||
);
|
||||
|
||||
-- Índices para tenants
|
||||
CREATE INDEX idx_tenants_slug ON tenants(slug);
|
||||
CREATE INDEX idx_tenants_status ON tenants(status);
|
||||
CREATE INDEX idx_tenants_domain ON tenants(domain);
|
||||
|
||||
-- ===================================
|
||||
-- DOMINIO: AUTH (Autenticación)
|
||||
-- ===================================
|
||||
|
||||
CREATE TYPE user_role_enum AS ENUM (
|
||||
'ADMIN', -- Control total (staff interno)
|
||||
'SUPPORT_MANAGER', -- Gestión de equipos y SLAs
|
||||
'AGENT', -- Atención de tickets
|
||||
'AUDITOR', -- Solo lectura para auditoría
|
||||
'CLIENT_ADMIN', -- Admin de organización cliente
|
||||
'CLIENT_USER' -- Usuario final cliente
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Básicos
|
||||
email VARCHAR(320) NOT NULL,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
avatar_url VARCHAR(500),
|
||||
|
||||
-- Auth
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role user_role_enum NOT NULL,
|
||||
|
||||
-- 2FA (opcional para staff interno)
|
||||
totp_secret VARCHAR(32),
|
||||
totp_enabled BOOLEAN DEFAULT FALSE,
|
||||
backup_codes TEXT[], -- códigos de recuperación
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
email_verified BOOLEAN DEFAULT FALSE,
|
||||
last_login TIMESTAMP WITH TIME ZONE,
|
||||
last_activity TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Preferences
|
||||
language VARCHAR(10) DEFAULT 'es',
|
||||
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||
notifications_email BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
UNIQUE(tenant_id, email)
|
||||
);
|
||||
|
||||
-- Índices para users
|
||||
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_role ON users(role);
|
||||
CREATE INDEX idx_users_active ON users(is_active);
|
||||
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);
|
||||
|
||||
-- Tabla para refresh tokens
|
||||
CREATE TABLE refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
device_info VARCHAR(500),
|
||||
ip_address INET,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
revoked BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Índices para refresh_tokens
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at);
|
||||
|
||||
-- ===================================
|
||||
-- DOMINIO: TICKETS (Core del negocio)
|
||||
-- ===================================
|
||||
|
||||
CREATE TYPE ticket_status_enum AS ENUM (
|
||||
'NEW',
|
||||
'TRIAGE',
|
||||
'IN_PROGRESS',
|
||||
'WAITING_CUSTOMER',
|
||||
'RESOLVED',
|
||||
'CLOSED',
|
||||
'REOPENED'
|
||||
);
|
||||
|
||||
CREATE TYPE ticket_priority_enum AS ENUM (
|
||||
'LOW',
|
||||
'MEDIUM',
|
||||
'HIGH',
|
||||
'URGENT'
|
||||
);
|
||||
|
||||
-- Categorías de tickets por tenant
|
||||
CREATE TABLE ticket_categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7), -- hex color
|
||||
sla_response_hours INTEGER DEFAULT 24,
|
||||
sla_resolution_hours INTEGER DEFAULT 72,
|
||||
auto_assign_to UUID REFERENCES users(id),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
-- Sistemas afectados por tenant
|
||||
CREATE TABLE affected_systems (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
-- Tickets principales
|
||||
CREATE TABLE tickets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
ticket_number VARCHAR(20) NOT NULL, -- formato: TKT-2024-000001
|
||||
|
||||
-- Básicos
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
|
||||
-- Clasificación
|
||||
category_id UUID REFERENCES ticket_categories(id),
|
||||
priority ticket_priority_enum DEFAULT 'MEDIUM',
|
||||
affected_system_id UUID REFERENCES affected_systems(id),
|
||||
|
||||
-- Asignación
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
assigned_to UUID REFERENCES users(id),
|
||||
|
||||
-- Estado
|
||||
status ticket_status_enum DEFAULT 'NEW',
|
||||
|
||||
-- SLA tracking
|
||||
sla_response_due TIMESTAMP WITH TIME ZONE,
|
||||
sla_resolution_due TIMESTAMP WITH TIME ZONE,
|
||||
first_response_at TIMESTAMP WITH TIME ZONE,
|
||||
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- CSAT (Customer Satisfaction)
|
||||
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
|
||||
rating_comment TEXT,
|
||||
rated_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
UNIQUE(tenant_id, ticket_number)
|
||||
);
|
||||
|
||||
-- Índices para tickets
|
||||
CREATE INDEX idx_tickets_tenant_id ON tickets(tenant_id);
|
||||
CREATE INDEX idx_tickets_number ON tickets(ticket_number);
|
||||
CREATE INDEX idx_tickets_created_by ON tickets(created_by);
|
||||
CREATE INDEX idx_tickets_assigned_to ON tickets(assigned_to);
|
||||
CREATE INDEX idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX idx_tickets_priority ON tickets(priority);
|
||||
CREATE INDEX idx_tickets_category ON tickets(category_id);
|
||||
CREATE INDEX idx_tickets_sla_response ON tickets(sla_response_due);
|
||||
CREATE INDEX idx_tickets_sla_resolution ON tickets(sla_resolution_due);
|
||||
CREATE INDEX idx_tickets_created_at ON tickets(created_at);
|
||||
|
||||
-- Comentarios en tickets
|
||||
CREATE TABLE ticket_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
|
||||
content TEXT NOT NULL,
|
||||
is_internal BOOLEAN DEFAULT FALSE, -- solo visible para staff interno
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Índices para comentarios
|
||||
CREATE INDEX idx_ticket_comments_ticket_id ON ticket_comments(ticket_id);
|
||||
CREATE INDEX idx_ticket_comments_author_id ON ticket_comments(author_id);
|
||||
CREATE INDEX idx_ticket_comments_created_at ON ticket_comments(created_at);
|
||||
|
||||
-- Adjuntos en tickets/comentarios
|
||||
CREATE TABLE ticket_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
comment_id UUID REFERENCES ticket_comments(id) ON DELETE CASCADE,
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
|
||||
-- Archivo
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
file_size INTEGER NOT NULL, -- bytes
|
||||
file_path VARCHAR(500) NOT NULL, -- path en storage
|
||||
|
||||
-- Checksums para integridad
|
||||
md5_hash VARCHAR(32),
|
||||
sha256_hash VARCHAR(64),
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Índices para adjuntos
|
||||
CREATE INDEX idx_ticket_attachments_ticket_id ON ticket_attachments(ticket_id);
|
||||
CREATE INDEX idx_ticket_attachments_comment_id ON ticket_attachments(comment_id);
|
||||
CREATE INDEX idx_ticket_attachments_uploaded_by ON ticket_attachments(uploaded_by);
|
||||
|
||||
-- Historial de cambios de estado
|
||||
CREATE TABLE ticket_status_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
changed_by UUID NOT NULL REFERENCES users(id),
|
||||
|
||||
old_status ticket_status_enum,
|
||||
new_status ticket_status_enum NOT NULL,
|
||||
old_assigned_to UUID REFERENCES users(id),
|
||||
new_assigned_to UUID REFERENCES users(id),
|
||||
|
||||
comment TEXT,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Índices para historial de estados
|
||||
CREATE INDEX idx_ticket_status_history_ticket_id ON ticket_status_history(ticket_id);
|
||||
CREATE INDEX idx_ticket_status_history_changed_by ON ticket_status_history(changed_by);
|
||||
CREATE INDEX idx_ticket_status_history_created_at ON ticket_status_history(created_at);
|
||||
|
||||
-- ===================================
|
||||
-- DOMINIO: NOTIFICATIONS (Comunicaciones)
|
||||
-- ===================================
|
||||
|
||||
-- Templates de email
|
||||
CREATE TABLE email_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID REFERENCES tenants(id), -- NULL = template global
|
||||
|
||||
name VARCHAR(100) NOT NULL,
|
||||
subject_template TEXT NOT NULL,
|
||||
body_template TEXT NOT NULL,
|
||||
template_type VARCHAR(50) NOT NULL, -- ticket_created, ticket_assigned, etc.
|
||||
|
||||
-- Variables disponibles en formato JSON
|
||||
available_variables JSONB,
|
||||
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Índices para templates
|
||||
CREATE INDEX idx_email_templates_tenant_id ON email_templates(tenant_id);
|
||||
CREATE INDEX idx_email_templates_type ON email_templates(template_type);
|
||||
|
||||
-- Log de notificaciones enviadas
|
||||
CREATE TABLE notification_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
|
||||
recipient_email VARCHAR(320) NOT NULL,
|
||||
subject VARCHAR(500) NOT NULL,
|
||||
template_type VARCHAR(50),
|
||||
|
||||
-- Metadata
|
||||
ticket_id UUID REFERENCES tickets(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
|
||||
-- Estado del envío
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed', 'bounced')),
|
||||
error_message TEXT,
|
||||
|
||||
-- Proveedor (ej: sendgrid, ses, smtp)
|
||||
provider VARCHAR(50),
|
||||
external_id VARCHAR(255), -- ID del proveedor
|
||||
|
||||
sent_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Índices para logs de notificaciones
|
||||
CREATE INDEX idx_notification_logs_tenant_id ON notification_logs(tenant_id);
|
||||
CREATE INDEX idx_notification_logs_recipient ON notification_logs(recipient_email);
|
||||
CREATE INDEX idx_notification_logs_ticket_id ON notification_logs(ticket_id);
|
||||
CREATE INDEX idx_notification_logs_status ON notification_logs(status);
|
||||
CREATE INDEX idx_notification_logs_created_at ON notification_logs(created_at);
|
||||
|
||||
-- ===================================
|
||||
-- DOMINIO: AUDIT (Bitácora)
|
||||
-- ===================================
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
user_id UUID REFERENCES users(id), -- NULL para acciones del sistema
|
||||
|
||||
-- Acción
|
||||
action VARCHAR(100) NOT NULL, -- ej: user.login, ticket.create, ticket.assign
|
||||
resource_type VARCHAR(50) NOT NULL, -- user, ticket, comment, etc.
|
||||
resource_id UUID,
|
||||
|
||||
-- Contexto
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
correlation_id UUID, -- para rastrear requests
|
||||
|
||||
-- Cambios (antes/después en JSON)
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
|
||||
-- Metadata adicional
|
||||
metadata JSONB,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Índices para audit logs
|
||||
CREATE INDEX idx_audit_logs_tenant_id ON audit_logs(tenant_id);
|
||||
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
|
||||
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
|
||||
CREATE INDEX idx_audit_logs_correlation_id ON audit_logs(correlation_id);
|
||||
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
|
||||
-- ===================================
|
||||
-- FUNCIONES Y TRIGGERS
|
||||
-- ===================================
|
||||
|
||||
-- Función para actualizar updated_at automáticamente
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Triggers para updated_at
|
||||
CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_tickets_updated_at BEFORE UPDATE ON tickets
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_ticket_comments_updated_at BEFORE UPDATE ON ticket_comments
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_email_templates_updated_at BEFORE UPDATE ON email_templates
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ===================================
|
||||
-- DATOS INICIALES (SEEDS)
|
||||
-- ===================================
|
||||
|
||||
-- Tenant de ejemplo para desarrollo
|
||||
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$...
|
||||
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',
|
||||
'ADMIN',
|
||||
true,
|
||||
true
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo';
|
||||
|
||||
-- Categorías por defecto
|
||||
INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours)
|
||||
SELECT
|
||||
id,
|
||||
'Soporte Técnico',
|
||||
'Problemas técnicos generales',
|
||||
2,
|
||||
24
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo'
|
||||
UNION ALL
|
||||
SELECT
|
||||
id,
|
||||
'Consulta Comercial',
|
||||
'Preguntas sobre productos y servicios',
|
||||
4,
|
||||
48
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo'
|
||||
UNION ALL
|
||||
SELECT
|
||||
id,
|
||||
'Incidente Crítico',
|
||||
'Problemas que afectan operaciones',
|
||||
1,
|
||||
8
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo';
|
||||
|
||||
-- Sistemas afectados por defecto
|
||||
INSERT INTO affected_systems (tenant_id, name, description)
|
||||
SELECT
|
||||
id,
|
||||
'Plataforma Web',
|
||||
'Sistema web principal'
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo'
|
||||
UNION ALL
|
||||
SELECT
|
||||
id,
|
||||
'API',
|
||||
'Servicios de API'
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo'
|
||||
UNION ALL
|
||||
SELECT
|
||||
id,
|
||||
'Base de Datos',
|
||||
'Sistemas de almacenamiento'
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo';
|
||||
|
||||
-- Templates de email básicos
|
||||
INSERT INTO email_templates (name, subject_template, body_template, template_type, available_variables)
|
||||
VALUES
|
||||
(
|
||||
'Ticket Creado',
|
||||
'Nuevo ticket #{{ticket_number}}: {{subject}}',
|
||||
'Hola {{user_name}},\n\nSe ha creado un nuevo ticket:\n\nNúmero: #{{ticket_number}}\nAsunto: {{subject}}\nPrioridad: {{priority}}\n\nPuedes ver los detalles en: {{ticket_url}}\n\nSaludos,\nEquipo de Soporte',
|
||||
'ticket_created',
|
||||
'{"ticket_number": "string", "subject": "string", "priority": "string", "user_name": "string", "ticket_url": "string"}'
|
||||
),
|
||||
(
|
||||
'Ticket Asignado',
|
||||
'Ticket #{{ticket_number}} asignado a ti',
|
||||
'Hola {{agent_name}},\n\nSe te ha asignado el ticket:\n\nNúmero: #{{ticket_number}}\nAsunto: {{subject}}\nCliente: {{customer_name}}\nPrioridad: {{priority}}\n\nPuedes verlo en: {{ticket_url}}\n\nSaludos,\nSistema de Tickets',
|
||||
'ticket_assigned',
|
||||
'{"ticket_number": "string", "subject": "string", "agent_name": "string", "customer_name": "string", "priority": "string", "ticket_url": "string"}'
|
||||
);
|
||||
|
||||
-- ===================================
|
||||
-- COMENTARIOS Y DOCUMENTACIÓN
|
||||
-- ===================================
|
||||
|
||||
COMMENT ON TABLE tenants IS 'Organizaciones cliente en el sistema multi-tenant';
|
||||
COMMENT ON TABLE users IS 'Usuarios del sistema (internos y clientes)';
|
||||
COMMENT ON TABLE tickets IS 'Tickets de soporte - core del negocio';
|
||||
COMMENT ON TABLE ticket_comments IS 'Comentarios en tickets';
|
||||
COMMENT ON TABLE ticket_attachments IS 'Archivos adjuntos en tickets';
|
||||
COMMENT ON TABLE audit_logs IS 'Bitácora de acciones para auditoría y compliance';
|
||||
294
docker-compose.yml
Normal file
294
docker-compose.yml
Normal file
@@ -0,0 +1,294 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ===================================
|
||||
# POSTGRES DATABASE
|
||||
# ===================================
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: servicemanager-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-servicemanager}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-servicemanager}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-servicemanager123}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- servicemanager-network
|
||||
|
||||
# ===================================
|
||||
# REDIS CACHE & BROKER
|
||||
# ===================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: servicemanager-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- servicemanager-network
|
||||
|
||||
# ===================================
|
||||
# FASTAPI BACKEND
|
||||
# ===================================
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: ../docker/Dockerfile.backend
|
||||
container_name: servicemanager-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- ENVIRONMENT=${ENVIRONMENT:-development}
|
||||
- DEBUG=${DEBUG:-true}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- uploads_data:/app/uploads
|
||||
- logs_data:/app/logs
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- servicemanager-network
|
||||
|
||||
# ===================================
|
||||
# CELERY WORKER
|
||||
# ===================================
|
||||
worker:
|
||||
build:
|
||||
context: ./workers
|
||||
dockerfile: ../docker/Dockerfile.worker
|
||||
container_name: servicemanager-worker
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- ENVIRONMENT=${ENVIRONMENT:-development}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
||||
volumes:
|
||||
- ./workers:/app
|
||||
- uploads_data:/app/uploads
|
||||
- logs_data:/app/logs
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- servicemanager-network
|
||||
|
||||
# ===================================
|
||||
# CELERY BEAT (SCHEDULER)
|
||||
# ===================================
|
||||
beat:
|
||||
build:
|
||||
context: ./workers
|
||||
dockerfile: ../docker/Dockerfile.worker
|
||||
container_name: servicemanager-beat
|
||||
restart: unless-stopped
|
||||
command: celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- ENVIRONMENT=${ENVIRONMENT:-development}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
|
||||
volumes:
|
||||
- ./workers:/app
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- servicemanager-network
|
||||
|
||||
# ===================================
|
||||
# FRONTEND CLIENT (CLIENTES)
|
||||
# ===================================
|
||||
frontend-client:
|
||||
build:
|
||||
context: ./frontend-client
|
||||
dockerfile: ../docker/Dockerfile.frontend
|
||||
args:
|
||||
- FRONTEND_TYPE=client
|
||||
container_name: servicemanager-client-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=${ENVIRONMENT:-development}
|
||||
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
|
||||
- PUBLIC_APP_NAME=ServiceManager Cliente
|
||||
volumes:
|
||||
- ./frontend-client:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- servicemanager-network
|
||||
|
||||
# ===================================
|
||||
# FRONTEND INTERNAL (STAFF INTERNO)
|
||||
# ===================================
|
||||
frontend-internal:
|
||||
build:
|
||||
context: ./frontend-internal
|
||||
dockerfile: ../docker/Dockerfile.frontend
|
||||
args:
|
||||
- FRONTEND_TYPE=internal
|
||||
container_name: servicemanager-internal-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=${ENVIRONMENT:-development}
|
||||
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
|
||||
- PUBLIC_APP_NAME=ServiceManager Admin
|
||||
volumes:
|
||||
- ./frontend-internal:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "3001:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- servicemanager-network
|
||||
|
||||
# ===================================
|
||||
# NGINX REVERSE PROXY
|
||||
# ===================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: servicemanager-nginx
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- uploads_data:/var/www/uploads:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend-client
|
||||
- frontend-internal
|
||||
networks:
|
||||
- servicemanager-network
|
||||
|
||||
# ===================================
|
||||
# DEVELOPMENT TOOLS
|
||||
# ===================================
|
||||
|
||||
# Adminer - Database Admin Interface
|
||||
adminer:
|
||||
image: adminer:4-standalone
|
||||
container_name: servicemanager-adminer
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ADMINER_DEFAULT_SERVER: postgres
|
||||
ADMINER_DESIGN: galkaev
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- servicemanager-network
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
# MailHog - Email Testing
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
container_name: servicemanager-mailhog
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1025:1025" # SMTP server
|
||||
- "8025:8025" # Web UI
|
||||
networks:
|
||||
- servicemanager-network
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
# Redis Commander - Redis GUI
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander:latest
|
||||
container_name: servicemanager-redis-commander
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REDIS_HOSTS: local:redis:6379
|
||||
ports:
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
- redis
|
||||
networks:
|
||||
- servicemanager-network
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
# ===================================
|
||||
# VOLUMES
|
||||
# ===================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: servicemanager_postgres_data
|
||||
redis_data:
|
||||
name: servicemanager_redis_data
|
||||
uploads_data:
|
||||
name: servicemanager_uploads_data
|
||||
logs_data:
|
||||
name: servicemanager_logs_data
|
||||
celery_beat_data:
|
||||
name: servicemanager_celery_beat_data
|
||||
|
||||
# ===================================
|
||||
# NETWORKS
|
||||
# ===================================
|
||||
networks:
|
||||
servicemanager-network:
|
||||
name: servicemanager_network
|
||||
driver: bridge
|
||||
|
||||
|
||||
43
docker/Dockerfile.backend
Normal file
43
docker/Dockerfile.backend
Normal file
@@ -0,0 +1,43 @@
|
||||
# FastAPI Backend Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Configurar directorio de trabajo
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar requirements y instalar dependencias Python
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Crear usuario no root
|
||||
RUN useradd --create-home --shell /bin/bash app \
|
||||
&& chown -R app:app /app
|
||||
|
||||
# Crear directorios necesarios
|
||||
RUN mkdir -p /app/uploads /app/logs \
|
||||
&& chown -R app:app /app/uploads /app/logs
|
||||
|
||||
# Copiar código de la aplicación
|
||||
COPY . .
|
||||
|
||||
# Cambiar permisos
|
||||
RUN chown -R app:app /app
|
||||
|
||||
# Cambiar a usuario no root
|
||||
USER app
|
||||
|
||||
# Exponer puerto
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Comando por defecto
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
42
docker/Dockerfile.frontend
Normal file
42
docker/Dockerfile.frontend
Normal file
@@ -0,0 +1,42 @@
|
||||
# Frontend Dockerfile (reutilizable para client e internal)
|
||||
FROM node:18-alpine
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
curl
|
||||
|
||||
# Configurar directorio de trabajo
|
||||
WORKDIR /app
|
||||
|
||||
# Crear usuario no root
|
||||
RUN addgroup -g 1001 -S nodejs \
|
||||
&& adduser -S app -u 1001 -G nodejs
|
||||
|
||||
# Copiar package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Configurar npm para ignorar certificados SSL (solo para desarrollo)
|
||||
RUN npm config set strict-ssl false
|
||||
|
||||
# Instalar dependencias
|
||||
RUN npm install --legacy-peer-deps && npm cache clean --force
|
||||
|
||||
# Copiar código de la aplicación
|
||||
COPY . .
|
||||
|
||||
# Cambiar permisos
|
||||
RUN chown -R app:nodejs /app
|
||||
|
||||
# Cambiar a usuario no root
|
||||
USER app
|
||||
|
||||
# Exponer puerto
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:3000 || exit 1
|
||||
|
||||
# Comando por defecto
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
35
docker/Dockerfile.worker
Normal file
35
docker/Dockerfile.worker
Normal file
@@ -0,0 +1,35 @@
|
||||
# Celery Worker Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Configurar directorio de trabajo
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar requirements y instalar dependencias Python
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Crear usuario no root
|
||||
RUN useradd --create-home --shell /bin/bash app \
|
||||
&& chown -R app:app /app
|
||||
|
||||
# Crear directorios necesarios
|
||||
RUN mkdir -p /app/uploads /app/logs \
|
||||
&& chown -R app:app /app/uploads /app/logs
|
||||
|
||||
# Copiar código de la aplicación
|
||||
COPY . .
|
||||
|
||||
# Cambiar permisos
|
||||
RUN chown -R app:app /app
|
||||
|
||||
# Cambiar a usuario no root
|
||||
USER app
|
||||
|
||||
# Comando por defecto
|
||||
CMD ["celery", "-A", "app.celery", "worker", "--loglevel=info"]
|
||||
144
docker/nginx/default.conf
Normal file
144
docker/nginx/default.conf
Normal file
@@ -0,0 +1,144 @@
|
||||
# ServiceManager Nginx Configuration
|
||||
|
||||
upstream backend_api {
|
||||
server backend:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream client_frontend {
|
||||
server frontend-client:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream internal_frontend {
|
||||
server frontend-internal:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# Client Portal (clientes.servicemanager.local o puerto 3000)
|
||||
server {
|
||||
listen 80;
|
||||
server_name clientes.servicemanager.local;
|
||||
|
||||
location / {
|
||||
proxy_pass http://client_frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
|
||||
# Internal Portal (admin.servicemanager.local o puerto 3001)
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.servicemanager.local;
|
||||
|
||||
location / {
|
||||
proxy_pass http://internal_frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
|
||||
# API Backend (api.servicemanager.local o puerto 8000)
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.servicemanager.local;
|
||||
|
||||
# API endpoints con rate limiting
|
||||
location /v1/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
|
||||
proxy_pass http://backend_api;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 30;
|
||||
proxy_send_timeout 300;
|
||||
}
|
||||
|
||||
# Auth endpoints con rate limiting más estricto
|
||||
location /v1/auth/ {
|
||||
limit_req zone=login burst=10 nodelay;
|
||||
|
||||
proxy_pass http://backend_api;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://backend_api;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# OpenAPI docs
|
||||
location ~ ^/(docs|openapi\.json|redoc) {
|
||||
proxy_pass http://backend_api;
|
||||
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;
|
||||
}
|
||||
|
||||
# Static files (uploads)
|
||||
location /uploads/ {
|
||||
alias /var/www/uploads/;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
# Validar que el usuario tenga permisos (implementar en backend)
|
||||
auth_request /auth;
|
||||
}
|
||||
|
||||
# Auth subrequest para archivos protegidos
|
||||
location = /auth {
|
||||
internal;
|
||||
proxy_pass http://backend_api/v1/auth/validate;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-URI $request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# Default server (localhost - desarrollo)
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
# Redirect to appropriate frontend based on path
|
||||
location /admin {
|
||||
return 301 http://localhost:3001/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend_api/;
|
||||
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;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 http://localhost:3000/;
|
||||
}
|
||||
}
|
||||
65
docker/nginx/nginx.conf
Normal file
65
docker/nginx/nginx.conf
Normal file
@@ -0,0 +1,65 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for" '
|
||||
'rt=$request_time uct="$upstream_connect_time" '
|
||||
'uht="$upstream_header_time" urt="$upstream_response_time"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
|
||||
# Hide server version
|
||||
server_tokens off;
|
||||
|
||||
# Include server configs
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
547
docs/api-contract.md
Normal file
547
docs/api-contract.md
Normal file
@@ -0,0 +1,547 @@
|
||||
# API Contract - ServiceManagerWeb
|
||||
# Mesa de Ayuda B2B - Especificación de Endpoints
|
||||
|
||||
## Base URL
|
||||
- Desarrollo: `http://localhost:8000`
|
||||
- Producción: `https://api.servicemanager.aduanasoft.com`
|
||||
|
||||
## Versionado
|
||||
- Todos los endpoints tienen prefijo `/v1/`
|
||||
- Versionado en URL path (no headers)
|
||||
|
||||
## Autenticación
|
||||
- JWT Bearer Token en header `Authorization: Bearer <token>`
|
||||
- Refresh token para renovación automática
|
||||
|
||||
## Headers Estándar
|
||||
```
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
X-Tenant-ID: <tenant_uuid> # Requerido para endpoints multi-tenant
|
||||
X-Correlation-ID: <uuid> # Opcional para tracking
|
||||
Accept-Language: es-ES # Para internacionalización
|
||||
```
|
||||
|
||||
## Responses Estándar
|
||||
|
||||
### Éxito (2xx)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"message": "Operación exitosa",
|
||||
"metadata": {
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"total": 150,
|
||||
"total_pages": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error (4xx/5xx)
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Datos inválidos",
|
||||
"details": [
|
||||
{
|
||||
"field": "email",
|
||||
"message": "Email inválido"
|
||||
}
|
||||
]
|
||||
},
|
||||
"correlation_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DOMINIO: AUTH
|
||||
|
||||
### POST /v1/auth/login
|
||||
**Descripción**: Autenticación de usuario
|
||||
**Público**: Sí
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123",
|
||||
"tenant_slug": "aduanasoft-demo",
|
||||
"totp_code": "123456" // opcional, solo si 2FA activado
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"access_token": "jwt_token",
|
||||
"refresh_token": "refresh_token",
|
||||
"expires_in": 3600,
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"email": "user@example.com",
|
||||
"first_name": "Juan",
|
||||
"last_name": "Pérez",
|
||||
"role": "AGENT",
|
||||
"tenant": {
|
||||
"id": "uuid",
|
||||
"name": "Aduanasoft Demo",
|
||||
"slug": "aduanasoft-demo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /v1/auth/refresh
|
||||
**Descripción**: Renovar access token
|
||||
**Público**: Sí
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"refresh_token": "refresh_token"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /v1/auth/logout
|
||||
**Descripción**: Cerrar sesión (revoca refresh token)
|
||||
**Autenticado**: Sí
|
||||
|
||||
### GET /v1/auth/me
|
||||
**Descripción**: Información del usuario actual
|
||||
**Autenticado**: Sí
|
||||
|
||||
### PUT /v1/auth/me
|
||||
**Descripción**: Actualizar perfil propio
|
||||
**Autenticado**: Sí
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"first_name": "Juan",
|
||||
"last_name": "Pérez",
|
||||
"language": "es",
|
||||
"timezone": "America/Mexico_City",
|
||||
"notifications_email": true
|
||||
}
|
||||
```
|
||||
|
||||
### POST /v1/auth/change-password
|
||||
**Descripción**: Cambiar contraseña
|
||||
**Autenticado**: Sí
|
||||
|
||||
### POST /v1/auth/2fa/setup
|
||||
**Descripción**: Configurar 2FA (solo roles internos)
|
||||
**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR
|
||||
|
||||
### POST /v1/auth/2fa/verify
|
||||
**Descripción**: Verificar código 2FA durante setup
|
||||
**Autenticado**: Sí
|
||||
|
||||
---
|
||||
|
||||
## DOMINIO: TENANTS
|
||||
|
||||
### GET /v1/tenants/current
|
||||
**Descripción**: Información del tenant actual
|
||||
**Autenticado**: Sí
|
||||
|
||||
### PUT /v1/tenants/current
|
||||
**Descripción**: Actualizar tenant (solo CLIENT_ADMIN/ADMIN)
|
||||
**Autenticado**: Sí, Roles: CLIENT_ADMIN, ADMIN
|
||||
|
||||
### GET /v1/tenants (solo ADMIN)
|
||||
**Descripción**: Listar todos los tenants
|
||||
**Autenticado**: Sí, Roles: ADMIN
|
||||
|
||||
### POST /v1/tenants (solo ADMIN)
|
||||
**Descripción**: Crear nuevo tenant
|
||||
**Autenticado**: Sí, Roles: ADMIN
|
||||
|
||||
---
|
||||
|
||||
## DOMINIO: USERS
|
||||
|
||||
### GET /v1/users
|
||||
**Descripción**: Listar usuarios del tenant
|
||||
**Autenticado**: Sí
|
||||
**Roles**: Todos (filtros por rol)
|
||||
|
||||
**Query Params**:
|
||||
```
|
||||
?page=1&per_page=20&role=AGENT&is_active=true&search=juan
|
||||
```
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"email": "agent@example.com",
|
||||
"first_name": "Juan",
|
||||
"last_name": "Agente",
|
||||
"role": "AGENT",
|
||||
"is_active": true,
|
||||
"email_verified": true,
|
||||
"last_login": "2024-01-15T10:30:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"total": 150,
|
||||
"total_pages": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /v1/users
|
||||
**Descripción**: Crear usuario
|
||||
**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, CLIENT_ADMIN
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"email": "nuevo@example.com",
|
||||
"first_name": "Nuevo",
|
||||
"last_name": "Usuario",
|
||||
"role": "AGENT",
|
||||
"password": "temporal123", // opcional, se genera automáticamente
|
||||
"send_welcome_email": true
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/users/{user_id}
|
||||
**Descripción**: Obtener usuario específico
|
||||
**Autenticado**: Sí
|
||||
|
||||
### PUT /v1/users/{user_id}
|
||||
**Descripción**: Actualizar usuario
|
||||
**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, CLIENT_ADMIN
|
||||
|
||||
### DELETE /v1/users/{user_id}
|
||||
**Descripción**: Desactivar usuario (soft delete)
|
||||
**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, CLIENT_ADMIN
|
||||
|
||||
---
|
||||
|
||||
## DOMINIO: TICKETS
|
||||
|
||||
### GET /v1/tickets
|
||||
**Descripción**: Listar tickets con filtros
|
||||
**Autenticado**: Sí
|
||||
|
||||
**Query Params**:
|
||||
```
|
||||
?page=1&per_page=20
|
||||
&status=NEW,IN_PROGRESS
|
||||
&priority=HIGH,URGENT
|
||||
&assigned_to=uuid
|
||||
&created_by=uuid
|
||||
&category_id=uuid
|
||||
&search=problema+conexion
|
||||
&sort=created_at_desc
|
||||
&date_from=2024-01-01
|
||||
&date_to=2024-01-31
|
||||
```
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"ticket_number": "TKT-2024-000001",
|
||||
"subject": "Problema de conexión",
|
||||
"status": "IN_PROGRESS",
|
||||
"priority": "HIGH",
|
||||
"category": {
|
||||
"id": "uuid",
|
||||
"name": "Soporte Técnico"
|
||||
},
|
||||
"created_by": {
|
||||
"id": "uuid",
|
||||
"first_name": "Juan",
|
||||
"last_name": "Cliente"
|
||||
},
|
||||
"assigned_to": {
|
||||
"id": "uuid",
|
||||
"first_name": "Ana",
|
||||
"last_name": "Soporte"
|
||||
},
|
||||
"sla_response_due": "2024-01-15T12:00:00Z",
|
||||
"sla_resolution_due": "2024-01-16T10:00:00Z",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T11:30:00Z"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"total": 150,
|
||||
"total_pages": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /v1/tickets
|
||||
**Descripción**: Crear nuevo ticket
|
||||
**Autenticado**: Sí
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"subject": "Problema de conexión con el sistema",
|
||||
"description": "Descripción detallada del problema...",
|
||||
"priority": "HIGH",
|
||||
"category_id": "uuid",
|
||||
"affected_system_id": "uuid",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "screenshot.png",
|
||||
"content_type": "image/png",
|
||||
"content_base64": "base64_data"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response 201**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"ticket_number": "TKT-2024-000001",
|
||||
"subject": "Problema de conexión con el sistema",
|
||||
"status": "NEW",
|
||||
"sla_response_due": "2024-01-15T12:00:00Z",
|
||||
"created_at": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/tickets/{ticket_id}
|
||||
**Descripción**: Obtener ticket completo con comentarios
|
||||
**Autenticado**: Sí
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"ticket_number": "TKT-2024-000001",
|
||||
"subject": "Problema de conexión",
|
||||
"description": "Descripción completa...",
|
||||
"status": "IN_PROGRESS",
|
||||
"priority": "HIGH",
|
||||
"category": {...},
|
||||
"affected_system": {...},
|
||||
"created_by": {...},
|
||||
"assigned_to": {...},
|
||||
"attachments": [...],
|
||||
"comments": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"content": "Comentario del ticket...",
|
||||
"author": {...},
|
||||
"is_internal": false,
|
||||
"attachments": [...],
|
||||
"created_at": "2024-01-15T11:00:00Z"
|
||||
}
|
||||
],
|
||||
"status_history": [...],
|
||||
"sla_metrics": {
|
||||
"response_due": "2024-01-15T12:00:00Z",
|
||||
"resolution_due": "2024-01-16T10:00:00Z",
|
||||
"first_response_at": "2024-01-15T11:15:00Z",
|
||||
"response_sla_met": true,
|
||||
"resolution_sla_met": null
|
||||
},
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T11:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /v1/tickets/{ticket_id}
|
||||
**Descripción**: Actualizar ticket (estado, asignación, etc.)
|
||||
**Autenticado**: Sí
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"status": "IN_PROGRESS",
|
||||
"assigned_to": "uuid",
|
||||
"priority": "URGENT",
|
||||
"comment": "Escalando por alta prioridad"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /v1/tickets/{ticket_id}/comments
|
||||
**Descripción**: Agregar comentario al ticket
|
||||
**Autenticado**: Sí
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"content": "Comentario con solución propuesta...",
|
||||
"is_internal": false,
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "solution.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"content_base64": "base64_data"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /v1/tickets/{ticket_id}/rating
|
||||
**Descripción**: Calificar ticket resuelto (solo cliente)
|
||||
**Autenticado**: Sí, Roles: CLIENT_ADMIN, CLIENT_USER
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"rating": 5,
|
||||
"comment": "Excelente atención y resolución rápida"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DOMINIO: CATEGORIES & SYSTEMS
|
||||
|
||||
### GET /v1/categories
|
||||
**Descripción**: Listar categorías del tenant
|
||||
**Autenticado**: Sí
|
||||
|
||||
### POST /v1/categories (solo staff interno)
|
||||
**Descripción**: Crear categoría
|
||||
**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER
|
||||
|
||||
### GET /v1/affected-systems
|
||||
**Descripción**: Listar sistemas afectados
|
||||
**Autenticado**: Sí
|
||||
|
||||
---
|
||||
|
||||
## DOMINIO: NOTIFICATIONS
|
||||
|
||||
### GET /v1/notifications/templates (solo staff interno)
|
||||
**Descripción**: Listar templates de email
|
||||
**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER
|
||||
|
||||
### POST /v1/notifications/test-email (solo ADMIN)
|
||||
**Descripción**: Enviar email de prueba
|
||||
**Autenticado**: Sí, Roles: ADMIN
|
||||
|
||||
---
|
||||
|
||||
## DOMINIO: REPORTS & ANALYTICS
|
||||
|
||||
### GET /v1/reports/dashboard
|
||||
**Descripción**: Métricas del dashboard
|
||||
**Autenticado**: Sí
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"tickets": {
|
||||
"total": 150,
|
||||
"new": 12,
|
||||
"in_progress": 45,
|
||||
"waiting_customer": 8,
|
||||
"resolved_today": 15
|
||||
},
|
||||
"sla": {
|
||||
"response_rate": 95.5,
|
||||
"resolution_rate": 87.2
|
||||
},
|
||||
"agents": {
|
||||
"active": 8,
|
||||
"avg_load": 5.6
|
||||
},
|
||||
"csat": {
|
||||
"average": 4.2,
|
||||
"total_responses": 89
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/reports/tickets
|
||||
**Descripción**: Reporte de tickets con filtros
|
||||
**Autenticado**: Sí
|
||||
|
||||
---
|
||||
|
||||
## DOMINIO: AUDIT
|
||||
|
||||
### GET /v1/audit/logs (solo AUDITOR/ADMIN)
|
||||
**Descripción**: Consultar logs de auditoría
|
||||
**Autenticado**: Sí, Roles: AUDITOR, ADMIN
|
||||
|
||||
**Query Params**:
|
||||
```
|
||||
?page=1&per_page=50
|
||||
&action=ticket.create,ticket.assign
|
||||
&user_id=uuid
|
||||
&resource_type=ticket
|
||||
&date_from=2024-01-01
|
||||
&date_to=2024-01-31
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CÓDIGOS DE ERROR ESTÁNDAR
|
||||
|
||||
- `400` - Bad Request (datos inválidos)
|
||||
- `401` - Unauthorized (no autenticado)
|
||||
- `403` - Forbidden (sin permisos)
|
||||
- `404` - Not Found (recurso no encontrado)
|
||||
- `409` - Conflict (recurso duplicado)
|
||||
- `422` - Unprocessable Entity (validación fallida)
|
||||
- `429` - Too Many Requests (rate limit)
|
||||
- `500` - Internal Server Error
|
||||
|
||||
## RATE LIMITING
|
||||
|
||||
- Auth endpoints: 10 req/min por IP
|
||||
- API endpoints: 100 req/min por usuario
|
||||
- File uploads: 5 req/min por usuario
|
||||
|
||||
## PAGINACIÓN
|
||||
|
||||
- Default: `per_page=20`, `max=100`
|
||||
- Links de navegación en metadata
|
||||
- Total count incluido cuando sea eficiente
|
||||
|
||||
## ORDENAMIENTO
|
||||
|
||||
Formato: `?sort=field_direction`
|
||||
- `created_at_desc` (default)
|
||||
- `updated_at_desc`
|
||||
- `priority_desc`
|
||||
- `status_asc`
|
||||
|
||||
## BÚSQUEDA
|
||||
|
||||
- Full-text search en `subject` y `description`
|
||||
- Búsqueda por número de ticket exacto
|
||||
- Filtros combinables con AND lógico
|
||||
96
docs/database-schema.md
Normal file
96
docs/database-schema.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Modelo de Datos - ServiceManagerWeb
|
||||
|
||||
## Resumen del Esquema
|
||||
|
||||
El sistema utiliza PostgreSQL con un diseño multi-tenant donde cada cliente (tenant) tiene sus datos aislados pero comparte la misma estructura de base de datos.
|
||||
|
||||
## Dominios Principales
|
||||
|
||||
### 1. **TENANTS** - Multi-tenancy
|
||||
- `tenants`: Organizaciones cliente
|
||||
- Cada tenant tiene configuraciones propias (usuarios max, storage, tipos de archivo)
|
||||
|
||||
### 2. **AUTH** - Autenticación y Autorización
|
||||
- `users`: Usuarios del sistema (internos y clientes)
|
||||
- `refresh_tokens`: Tokens de refresco para JWT
|
||||
- Roles: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR, CLIENT_ADMIN, CLIENT_USER
|
||||
- Soporte para 2FA (TOTP) opcional para staff interno
|
||||
|
||||
### 3. **TICKETS** - Core del Negocio
|
||||
- `tickets`: Tickets de soporte principales
|
||||
- `ticket_categories`: Categorías personalizables por tenant
|
||||
- `affected_systems`: Sistemas afectados por tenant
|
||||
- `ticket_comments`: Conversación en tickets
|
||||
- `ticket_attachments`: Archivos adjuntos
|
||||
- `ticket_status_history`: Historial de cambios de estado
|
||||
|
||||
Estados de ticket: NEW → TRIAGE → IN_PROGRESS → WAITING_CUSTOMER → RESOLVED → CLOSED
|
||||
Prioridades: LOW, MEDIUM, HIGH, URGENT
|
||||
|
||||
### 4. **NOTIFICATIONS** - Comunicaciones
|
||||
- `email_templates`: Templates personalizables por tenant
|
||||
- `notification_logs`: Historial de emails enviados
|
||||
- Soporte para variables dinámicas en templates
|
||||
|
||||
### 5. **AUDIT** - Bitácora y Compliance
|
||||
- `audit_logs`: Registro completo de acciones
|
||||
- Tracking con correlation_id para requests
|
||||
- Almacena cambios antes/después en JSON
|
||||
|
||||
## Características Técnicas
|
||||
|
||||
### Índices Estratégicos
|
||||
- Optimizados para queries por tenant
|
||||
- Indices compuestos para búsquedas frecuentes
|
||||
- Índices en campos de fecha para reportes
|
||||
|
||||
### Constraints y Validación
|
||||
- CHECK constraints para valores enum
|
||||
- Foreign keys con CASCADE apropiados
|
||||
- UNIQUE constraints compuestos (tenant_id + campo)
|
||||
|
||||
### Triggers Automáticos
|
||||
- `updated_at` se actualiza automáticamente
|
||||
- Preparado para audit logging automático
|
||||
|
||||
### Multi-tenancy
|
||||
- Todos los datos principales tienen `tenant_id`
|
||||
- Aislamiento a nivel de aplicación
|
||||
- Configuraciones por tenant (SLA, categorías, etc.)
|
||||
|
||||
## Numeración de Tickets
|
||||
|
||||
Formato: `TKT-YYYY-NNNNNN` (ej: TKT-2026-000001)
|
||||
- Único por tenant
|
||||
- Año incluido para fácil organización
|
||||
- 6 dígitos con ceros a la izquierda
|
||||
|
||||
## SLA Tracking
|
||||
|
||||
- `sla_response_due`: Tiempo límite para primera respuesta
|
||||
- `sla_resolution_due`: Tiempo límite para resolución
|
||||
- `first_response_at`: Timestamp de primera respuesta
|
||||
- Configurables por categoría
|
||||
|
||||
## Almacenamiento de Archivos
|
||||
|
||||
- Metadata en BD, archivos en filesystem/S3
|
||||
- Checksums MD5 y SHA256 para integridad
|
||||
- Validación de tipos MIME
|
||||
- Límites de tamaño por tenant
|
||||
|
||||
## Datos Iniciales
|
||||
|
||||
El schema incluye:
|
||||
- Tenant demo para desarrollo
|
||||
- Usuario admin por defecto
|
||||
- Categorías base (Soporte Técnico, Consulta Comercial, Incidente Crítico)
|
||||
- Sistemas base (Plataforma Web, API, Base de Datos)
|
||||
- Templates de email básicos
|
||||
|
||||
## Escalabilidad
|
||||
|
||||
- Preparado para sharding por tenant_id
|
||||
- Partitioning por fecha en audit_logs
|
||||
- Índices optimizados para paginación
|
||||
- Soft deletes donde aplique
|
||||
42
frontend-client/.eslintrc.json
Normal file
42
frontend-client/.eslintrc.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "svelte3"],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2017": true,
|
||||
"node": true
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.svelte"],
|
||||
"processor": "svelte3/svelte3"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ "argsIgnorePattern": "^_" }
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }],
|
||||
"prefer-const": "error"
|
||||
},
|
||||
"settings": {
|
||||
"svelte3/typescript": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
".svelte-kit/**",
|
||||
"build/**",
|
||||
"dist/**",
|
||||
"node_modules/**"
|
||||
]
|
||||
}
|
||||
11
frontend-client/.prettierignore
Normal file
11
frontend-client/.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.svelte-kit
|
||||
build
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
.DS_Store
|
||||
*.log
|
||||
coverage
|
||||
.nyc_output
|
||||
22
frontend-client/.prettierrc
Normal file
22
frontend-client/.prettierrc
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"quoteProps": "as-needed",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
47
frontend-client/package.json
Normal file
47
frontend-client/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@servicemanager/client-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.20.4",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte": "^2.30.0",
|
||||
"postcss": "^8.4.24",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.10.1",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-check": "^3.4.3",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.4.2",
|
||||
"vitest": "^0.34.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/forms": "^0.5.4",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"heroicons": "^2.0.18",
|
||||
"zod": "^3.22.2",
|
||||
"date-fns": "^2.30.0"
|
||||
}
|
||||
}
|
||||
6
frontend-client/postcss.config.js
Normal file
6
frontend-client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
183
frontend-client/src/app.css
Normal file
183
frontend-client/src/app.css
Normal file
@@ -0,0 +1,183 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom base styles */
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-gray-900 bg-gray-50;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus {
|
||||
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
|
||||
}
|
||||
|
||||
/* Selection styles */
|
||||
::selection {
|
||||
@apply bg-primary-100 text-primary-900;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom component styles */
|
||||
@layer components {
|
||||
/* Card component */
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none px-4 py-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 active:bg-secondary-300;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-success-600 text-white hover:bg-success-700 active:bg-success-800;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply bg-warning-600 text-white hover:bg-warning-700 active:bg-warning-800;
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
@apply bg-error-600 text-white hover:bg-error-700 active:bg-error-800;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply bg-transparent hover:bg-secondary-100 active:bg-secondary-200;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply p-6 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@apply p-6 border-t border-gray-200 bg-gray-50 rounded-b-lg;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.form-input {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply text-sm text-error-600 mt-1;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
@apply badge bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.badge-in-progress {
|
||||
@apply badge bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.badge-waiting {
|
||||
@apply badge bg-orange-100 text-orange-800;
|
||||
}
|
||||
|
||||
.badge-resolved {
|
||||
@apply badge bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.badge-closed {
|
||||
@apply badge bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
.badge-reopened {
|
||||
@apply badge bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
/* Priority badges */
|
||||
.badge-priority-low {
|
||||
@apply badge bg-gray-100 text-gray-600;
|
||||
}
|
||||
|
||||
.badge-priority-medium {
|
||||
@apply badge bg-blue-100 text-blue-700;
|
||||
}
|
||||
|
||||
.badge-priority-high {
|
||||
@apply badge bg-orange-100 text-orange-700;
|
||||
}
|
||||
|
||||
.badge-priority-urgent {
|
||||
@apply badge bg-red-100 text-red-700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom utility classes */
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom keyframes */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
30
frontend-client/src/app.html
Normal file
30
frontend-client/src/app.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="ServiceManager - Portal de Soporte Técnico para Clientes" />
|
||||
<meta name="keywords" content="soporte técnico, mesa de ayuda, tickets, aduanasoft" />
|
||||
<meta name="author" content="Aduanasoft" />
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="ServiceManager - Portal Cliente" />
|
||||
<meta property="og:description" content="Gestiona tus tickets de soporte técnico" />
|
||||
<meta property="og:site_name" content="ServiceManager" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="min-h-screen bg-gray-50 antialiased">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
130
frontend-client/src/lib/components/Header.svelte
Normal file
130
frontend-client/src/lib/components/Header.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
export let showLogo = true;
|
||||
export let showNavigation = true;
|
||||
|
||||
let isMenuOpen = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
});
|
||||
|
||||
function toggleMenu() {
|
||||
isMenuOpen = !isMenuOpen;
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout();
|
||||
isMenuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- Logo -->
|
||||
{#if showLogo}
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="flex items-center space-x-2">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<Icon name="ticket" size="w-5 h-5" className="text-white" />
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<h1 class="text-xl font-semibold text-gray-900">ServiceManager</h1>
|
||||
<p class="text-xs text-gray-500">Mesa de Ayuda</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
{#if showNavigation && $auth.isAuthenticated}
|
||||
<nav class="hidden md:flex space-x-8">
|
||||
<a href="/tickets" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
|
||||
Mis Tickets
|
||||
</a>
|
||||
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
|
||||
Crear Ticket
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<!-- User menu -->
|
||||
<div class="flex items-center space-x-4">
|
||||
{#if $auth.isAuthenticated}
|
||||
<div class="relative">
|
||||
<button
|
||||
on:click={toggleMenu}
|
||||
class="flex items-center space-x-2 text-gray-700 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-md p-2"
|
||||
>
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-primary-600 text-sm font-medium">
|
||||
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<span class="hidden sm:block text-sm">
|
||||
{$auth.user?.first_name} {$auth.user?.last_name}
|
||||
</span>
|
||||
<Icon name="chevronDown" size="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{#if isMenuOpen}
|
||||
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50">
|
||||
<div class="py-1">
|
||||
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
|
||||
{$auth.user?.email}
|
||||
</div>
|
||||
<a
|
||||
href="/profile"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
on:click={() => isMenuOpen = false}
|
||||
>
|
||||
Mi Perfil
|
||||
</a>
|
||||
<button
|
||||
on:click={handleLogout}
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
href="/login"
|
||||
class="text-gray-700 hover:text-primary-600 text-sm font-medium"
|
||||
>
|
||||
Iniciar Sesión
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile navigation -->
|
||||
{#if showNavigation && $auth.isAuthenticated}
|
||||
<div class="md:hidden border-t border-gray-200 py-2">
|
||||
<nav class="flex space-x-4">
|
||||
<a href="/tickets" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
|
||||
Mis Tickets
|
||||
</a>
|
||||
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
|
||||
Crear Ticket
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Backdrop for mobile menu -->
|
||||
{#if isMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40 md:hidden"
|
||||
on:click={() => isMenuOpen = false}
|
||||
></div>
|
||||
{/if}
|
||||
39
frontend-client/src/lib/components/Icon.svelte
Normal file
39
frontend-client/src/lib/components/Icon.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
export let size: string = 'w-5 h-5';
|
||||
export let className: string = '';
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
||||
ticket: 'M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z',
|
||||
plus: 'M12 4v16m8-8H4',
|
||||
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||
x: 'M6 18L18 6M6 6l12 12',
|
||||
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
|
||||
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||
edit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
|
||||
trash: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
|
||||
eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z',
|
||||
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
check: 'M5 13l4 4L19 7',
|
||||
chevronDown: 'M19 9l-7 7-7-7'
|
||||
};
|
||||
|
||||
$: path = icons[name] || icons.home;
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="{size} {className}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={path}
|
||||
/>
|
||||
</svg>
|
||||
124
frontend-client/src/lib/components/TicketCard.svelte
Normal file
124
frontend-client/src/lib/components/TicketCard.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
export let ticket: import('$lib/stores/tickets').Ticket;
|
||||
|
||||
// Status mapping
|
||||
const statusConfig = {
|
||||
NEW: { label: 'Nuevo', class: 'badge-new' },
|
||||
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
|
||||
WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
|
||||
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
|
||||
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
|
||||
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
|
||||
};
|
||||
|
||||
// Priority mapping
|
||||
const priorityConfig = {
|
||||
LOW: { label: 'Baja', class: 'badge-priority-low' },
|
||||
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
|
||||
HIGH: { label: 'Alta', class: 'badge-priority-high' },
|
||||
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
|
||||
};
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Format relative time
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 1) return 'hace un momento';
|
||||
if (diffInMinutes < 60) return `hace ${diffInMinutes}m`;
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) return `hace ${diffInHours}h`;
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 7) return `hace ${diffInDays}d`;
|
||||
|
||||
return formatDate(dateString);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card hover:shadow-md transition-shadow">
|
||||
<div class="card-content">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 line-clamp-2">
|
||||
<a href="/tickets/{ticket.id}" class="hover:text-primary-600">
|
||||
{ticket.title}
|
||||
</a>
|
||||
</h3>
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<span class={`${statusConfig[ticket.status].class}`}>
|
||||
{statusConfig[ticket.status].label}
|
||||
</span>
|
||||
<span class={`${priorityConfig[ticket.priority].class}`}>
|
||||
{priorityConfig[ticket.priority].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 text-sm line-clamp-3 mb-4">
|
||||
{ticket.description}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-between items-center text-xs text-gray-500">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span>#{ticket.id.substring(0, 8)}</span>
|
||||
{#if ticket.category_name}
|
||||
<span class="bg-gray-100 text-gray-600 px-2 py-1 rounded">
|
||||
{ticket.category_name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if ticket.assigned_to_name}
|
||||
<span class="flex items-center space-x-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>{ticket.assigned_to_name}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
{#if ticket.due_date}
|
||||
<span class="flex items-center space-x-1 {new Date(ticket.due_date) < new Date() ? 'text-red-600' : ''}">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Vence {formatRelativeTime(ticket.due_date)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span title={formatDate(ticket.updated_at)}>
|
||||
Actualizado {formatRelativeTime(ticket.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
117
frontend-client/src/lib/components/Toast.svelte
Normal file
117
frontend-client/src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
export let type: 'success' | 'error' | 'warning' | 'info' = 'info';
|
||||
export let message: string;
|
||||
export let duration: number = 5000;
|
||||
export let dismissible: boolean = true;
|
||||
|
||||
let visible = true;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
// Auto-dismiss after duration
|
||||
if (duration > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
visible = false;
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
visible = false;
|
||||
}
|
||||
|
||||
// Cleanup timeout on destroy
|
||||
import { onDestroy } from 'svelte';
|
||||
onDestroy(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
// Style mapping
|
||||
const typeStyles = {
|
||||
success: {
|
||||
container: 'bg-success-50 border-success-200 text-success-800',
|
||||
icon: 'text-success-400'
|
||||
},
|
||||
error: {
|
||||
container: 'bg-error-50 border-error-200 text-error-800',
|
||||
icon: 'text-error-400'
|
||||
},
|
||||
warning: {
|
||||
container: 'bg-warning-50 border-warning-200 text-warning-800',
|
||||
icon: 'text-warning-400'
|
||||
},
|
||||
info: {
|
||||
container: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
icon: 'text-blue-400'
|
||||
}
|
||||
};
|
||||
|
||||
// Icon mapping
|
||||
const typeIcons = {
|
||||
success: 'M5 13l4 4L19 7',
|
||||
error: 'M6 18L18 6M6 6l12 12',
|
||||
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z',
|
||||
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="fixed top-4 right-4 max-w-sm w-full z-50 animate-slide-up">
|
||||
<div class="rounded-lg border p-4 shadow-lg {typeStyles[type].container}">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 {typeStyles[type].icon}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={typeIcons[type]}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if dismissible}
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 {typeStyles[type].icon} hover:opacity-75"
|
||||
on:click={dismiss}
|
||||
>
|
||||
<span class="sr-only">Cerrar</span>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
80
frontend-client/src/lib/stores/app.ts
Normal file
80
frontend-client/src/lib/stores/app.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { auth } from './auth.js';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
tenant_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
categories: Category[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: AppState = {
|
||||
categories: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// API helper function
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const authState = get(auth);
|
||||
|
||||
const response = await fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Create app store
|
||||
function createAppStore() {
|
||||
const { subscribe, set, update }: Writable<AppState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Load categories
|
||||
loadCategories: async () => {
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const categories = await apiCall('/categories/');
|
||||
update(state => ({ ...state, categories, isLoading: false }));
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load categories'
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// Clear error
|
||||
clearError: () => {
|
||||
update(state => ({ ...state, error: null }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const app = createAppStore();
|
||||
139
frontend-client/src/lib/stores/auth.ts
Normal file
139
frontend-client/src/lib/stores/auth.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
tenant_id: string;
|
||||
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
|
||||
is_active: boolean;
|
||||
is_two_factor_enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
tenant_slug: string;
|
||||
totp_code?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
};
|
||||
|
||||
// Create auth store
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize auth from localStorage
|
||||
init: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const user = localStorage.getItem('auth_user');
|
||||
|
||||
if (token && user) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(user);
|
||||
set({
|
||||
user: parsedUser,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored auth data:', error);
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Login
|
||||
login: async (credentials: LoginRequest): Promise<void> => {
|
||||
update(state => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
|
||||
// Store auth data
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', data.access_token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||
}
|
||||
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.access_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
}
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
// Update user data
|
||||
updateUser: (user: User) => {
|
||||
update(state => ({ ...state, user }));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
},
|
||||
|
||||
// Set loading state
|
||||
setLoading: (isLoading: boolean) => {
|
||||
update(state => ({ ...state, isLoading }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuthStore();
|
||||
272
frontend-client/src/lib/stores/tickets.ts
Normal file
272
frontend-client/src/lib/stores/tickets.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { auth } from './auth.js';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'NEW' | 'IN_PROGRESS' | 'WAITING_FOR_CLIENT' | 'RESOLVED' | 'CLOSED' | 'REOPENED';
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
category_id: string;
|
||||
category_name?: string;
|
||||
client_id: string;
|
||||
assigned_to_id: string | null;
|
||||
assigned_to_name?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
due_date: string | null;
|
||||
resolution: string | null;
|
||||
}
|
||||
|
||||
export interface TicketComment {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_role: string;
|
||||
content: string;
|
||||
is_internal: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TicketAttachment {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
filename: string;
|
||||
original_filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
uploaded_by_id: string;
|
||||
uploaded_by_name: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTicketRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
category_id: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
}
|
||||
|
||||
export interface TicketsState {
|
||||
tickets: Ticket[];
|
||||
currentTicket: Ticket | null;
|
||||
comments: TicketComment[];
|
||||
attachments: TicketAttachment[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: TicketsState = {
|
||||
tickets: [],
|
||||
currentTicket: null,
|
||||
comments: [],
|
||||
attachments: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// API helper function
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const authState = get(auth);
|
||||
|
||||
const response = await fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Create tickets store
|
||||
function createTicketsStore() {
|
||||
const { subscribe, set, update }: Writable<TicketsState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Load user's tickets
|
||||
loadTickets: async () => {
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const tickets = await apiCall('/tickets/');
|
||||
update(state => ({ ...state, tickets, isLoading: false }));
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load tickets'
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// Load specific ticket with details
|
||||
loadTicket: async (ticketId: string) => {
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const [ticket, comments, attachments] = await Promise.all([
|
||||
apiCall(`/tickets/${ticketId}`),
|
||||
apiCall(`/tickets/${ticketId}/comments`),
|
||||
apiCall(`/tickets/${ticketId}/attachments`)
|
||||
]);
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: ticket,
|
||||
comments,
|
||||
attachments,
|
||||
isLoading: false
|
||||
}));
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load ticket'
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// Create new ticket
|
||||
createTicket: async (ticket: CreateTicketRequest) => {
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const newTicket = await apiCall('/tickets/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ticket)
|
||||
});
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
tickets: [newTicket, ...state.tickets],
|
||||
isLoading: false
|
||||
}));
|
||||
|
||||
return newTicket;
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create ticket'
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Add comment to ticket
|
||||
addComment: async (ticketId: string, content: string) => {
|
||||
try {
|
||||
const comment = await apiCall(`/tickets/${ticketId}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
comments: [...state.comments, comment]
|
||||
}));
|
||||
|
||||
return comment;
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to add comment'
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Upload attachment
|
||||
uploadAttachment: async (ticketId: string, file: File) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const authState = get(auth);
|
||||
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authState.token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
const attachment = await response.json();
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
attachments: [...state.attachments, attachment]
|
||||
}));
|
||||
|
||||
return attachment;
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to upload attachment'
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Close ticket (client can close their own tickets)
|
||||
closeTicket: async (ticketId: string, resolution?: string) => {
|
||||
try {
|
||||
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ resolution })
|
||||
});
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
|
||||
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
|
||||
}));
|
||||
|
||||
return updatedTicket;
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to close ticket'
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear error
|
||||
clearError: () => {
|
||||
update(state => ({ ...state, error: null }));
|
||||
},
|
||||
|
||||
// Clear current ticket
|
||||
clearCurrentTicket: () => {
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: null,
|
||||
comments: [],
|
||||
attachments: []
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const tickets = createTicketsStore();
|
||||
75
frontend-client/src/lib/stores/toast.ts
Normal file
75
frontend-client/src/lib/stores/toast.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Toast notification store
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: ToastMessage[];
|
||||
}
|
||||
|
||||
const initialState: ToastState = {
|
||||
toasts: []
|
||||
};
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update }: Writable<ToastState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
show: (type: ToastMessage['type'], message: string, duration = 5000) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const toast: ToastMessage = { id, type, message, duration };
|
||||
|
||||
update(state => ({
|
||||
toasts: [...state.toasts, toast]
|
||||
}));
|
||||
|
||||
// Auto-remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
update(state => ({
|
||||
toasts: state.toasts.filter(t => t.id !== id)
|
||||
}));
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
dismiss: (id: string) => {
|
||||
update(state => ({
|
||||
toasts: state.toasts.filter(t => t.id !== id)
|
||||
}));
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
update(() => initialState);
|
||||
},
|
||||
|
||||
// Convenience methods
|
||||
success: (message: string, duration?: number) => {
|
||||
return createToastStore().show('success', message, duration);
|
||||
},
|
||||
|
||||
error: (message: string, duration?: number) => {
|
||||
return createToastStore().show('error', message, duration);
|
||||
},
|
||||
|
||||
warning: (message: string, duration?: number) => {
|
||||
return createToastStore().show('warning', message, duration);
|
||||
},
|
||||
|
||||
info: (message: string, duration?: number) => {
|
||||
return createToastStore().show('info', message, duration);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
35
frontend-client/src/routes/+layout.svelte
Normal file
35
frontend-client/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { page } from '$app/stores';
|
||||
import '../app.css';
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
});
|
||||
|
||||
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register');
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 font-sans">
|
||||
{#if showHeader}
|
||||
<Header />
|
||||
{/if}
|
||||
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
{#each $toast.toasts as toastMessage (toastMessage.id)}
|
||||
<Toast
|
||||
type={toastMessage.type}
|
||||
message={toastMessage.message}
|
||||
duration={toastMessage.duration}
|
||||
on:dismiss={() => toast.dismiss(toastMessage.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
178
frontend-client/src/routes/+page.svelte
Normal file
178
frontend-client/src/routes/+page.svelte
Normal file
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { tickets } from '$lib/stores/tickets.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if not authenticated
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load user's tickets
|
||||
tickets.loadTickets();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>ServiceManager - Mesa de Ayuda</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Welcome Section -->
|
||||
<div class="bg-gradient-to-r from-primary-500 to-primary-600 rounded-lg p-8 text-white mb-8">
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-3xl font-bold mb-2">
|
||||
Bienvenido, {$auth.user?.first_name} {$auth.user?.last_name}
|
||||
</h1>
|
||||
<p class="text-primary-100 text-lg">
|
||||
Gestiona tus tickets de soporte de manera eficiente. Crea nuevos tickets,
|
||||
da seguimiento a los existentes y mantente actualizado con el estado de tus solicitudes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<a
|
||||
href="/tickets/new"
|
||||
class="card hover:shadow-lg transition-shadow group cursor-pointer"
|
||||
>
|
||||
<div class="card-content text-center">
|
||||
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-primary-200 transition-colors">
|
||||
<Icon name="plus" size="w-6 h-6" className="text-primary-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Crear Ticket</h3>
|
||||
<p class="text-gray-600 text-sm">
|
||||
Reporta un problema o solicita soporte técnico
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/tickets"
|
||||
class="card hover:shadow-lg transition-shadow group cursor-pointer"
|
||||
>
|
||||
<div class="card-content text-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-blue-200 transition-colors">
|
||||
<Icon name="ticket" size="w-6 h-6" className="text-blue-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Mis Tickets</h3>
|
||||
<p class="text-gray-600 text-sm">
|
||||
Consulta el estado de todos tus tickets
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/profile"
|
||||
class="card hover:shadow-lg transition-shadow group cursor-pointer"
|
||||
>
|
||||
<div class="card-content text-center">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-green-200 transition-colors">
|
||||
<Icon name="user" size="w-6 h-6" className="text-green-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Mi Perfil</h3>
|
||||
<p class="text-gray-600 text-sm">
|
||||
Actualiza tu información personal
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Recent Tickets -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Tickets Recientes</h2>
|
||||
<p class="text-gray-600 mt-1">Últimos tickets que has creado o actualizado</p>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
{#if $tickets.isLoading}
|
||||
<div class="text-center py-8">
|
||||
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">Cargando tickets...</p>
|
||||
</div>
|
||||
{:else if $tickets.error}
|
||||
<div class="text-center py-8">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">Error al cargar los tickets</p>
|
||||
<button
|
||||
on:click={() => tickets.loadTickets()}
|
||||
class="btn-primary px-4 py-2"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
{:else if $tickets.tickets.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">No tienes tickets creados</p>
|
||||
<a href="/tickets/new" class="btn-primary px-4 py-2">
|
||||
Crear tu primer ticket
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each $tickets.tickets.slice(0, 5) as ticket (ticket.id)}
|
||||
<div class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-gray-900 mb-1">
|
||||
<a href="/tickets/{ticket.id}" class="hover:text-primary-600">
|
||||
{ticket.title}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm line-clamp-2 mb-2">
|
||||
{ticket.description}
|
||||
</p>
|
||||
<div class="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>#{ticket.id.substring(0, 8)}</span>
|
||||
<span>{new Date(ticket.created_at).toLocaleDateString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<span class="badge-{ticket.status.toLowerCase().replace('_', '-')}">
|
||||
{ticket.status === 'NEW' ? 'Nuevo' :
|
||||
ticket.status === 'IN_PROGRESS' ? 'En Progreso' :
|
||||
ticket.status === 'WAITING_FOR_CLIENT' ? 'Esperando Cliente' :
|
||||
ticket.status === 'RESOLVED' ? 'Resuelto' :
|
||||
ticket.status === 'CLOSED' ? 'Cerrado' : 'Reabierto'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if $tickets.tickets.length > 5}
|
||||
<div class="text-center pt-4 border-t border-gray-200">
|
||||
<a href="/tickets" class="btn-secondary px-4 py-2">
|
||||
Ver todos los tickets ({$tickets.tickets.length})
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
244
frontend-client/src/routes/login/+page.svelte
Normal file
244
frontend-client/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let totpCode = '';
|
||||
let isLoading = false;
|
||||
let showTwoFactor = false;
|
||||
let errorMessage = '';
|
||||
let showPassword = false;
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if already authenticated
|
||||
if ($auth.isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
if (!email || !password) {
|
||||
errorMessage = 'Por favor completa todos los campos';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
|
||||
try {
|
||||
await auth.login({
|
||||
email,
|
||||
password,
|
||||
tenant_slug: 'aduanasoft', // Default tenant for now
|
||||
totp_code: totpCode || undefined
|
||||
});
|
||||
|
||||
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
||||
goto('/');
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
// Check if 2FA is required
|
||||
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
||||
showTwoFactor = true;
|
||||
errorMessage = 'Introduce el código de tu aplicación de autenticación';
|
||||
} else {
|
||||
errorMessage = error.message || 'Error al iniciar sesión';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleLogin();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<div class="min-h-screen flex font-sans bg-white overflow-hidden">
|
||||
|
||||
<!-- Left Side: Hero Image & Overlay (55% width) -->
|
||||
<div class="hidden lg:flex w-[55%] relative bg-gray-900">
|
||||
<!-- Background Image -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center z-0"
|
||||
style="background-image: url('/images/SOPORTE.webp'); opacity: 1;"
|
||||
></div>
|
||||
|
||||
<!-- Gradient Overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[#1e3a8a]/75 to-[#172554]/75 z-10"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent z-10"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-20 w-full h-full flex flex-col justify-between p-16 text-white">
|
||||
<!-- Top Logo (Left) -->
|
||||
<div class="flex flex-col">
|
||||
<img src="/images/Logo%20AS%20blanco(1).png" alt="AduanaSoft" class="h-32 w-auto object-contain self-start drop-shadow-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Main Hero Text -->
|
||||
<div class="space-y-4 mb-12">
|
||||
<h2 class="text-5xl font-extrabold tracking-tight drop-shadow-xl leading-tight">
|
||||
Control Total <br/>
|
||||
de Servicios de TI
|
||||
</h2>
|
||||
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
|
||||
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y reciba asistencia especializada para garantizar la continuidad de su operación.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Footer -->
|
||||
<div class="text-xs font-bold tracking-[0.2em] text-blue-200/60 uppercase">
|
||||
ServiceManager Enterprise Platform
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Login Form (45% width) -->
|
||||
<div class="w-full lg:w-[45%] flex flex-col justify-center items-center p-8 lg:p-16 bg-white relative">
|
||||
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
<!-- Logo & Header -->
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-3xl font-bold text-gray-900">Bienvenido</h2>
|
||||
<p class="text-gray-500 text-sm">Ingrese a su cuenta corporativa</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form on:submit|preventDefault={handleLogin} class="space-y-6 mt-8">
|
||||
{#if errorMessage}
|
||||
<div class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600">
|
||||
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showTwoFactor}
|
||||
<div class="space-y-5">
|
||||
<!-- Email Input -->
|
||||
<div class="space-y-1.5">
|
||||
<label for="email" class="block text-sm font-semibold text-gray-700">Correo Electrónico</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon name="mail" class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
on:keydown={handleKeyDown}
|
||||
class="block w-full pl-10 pr-3 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
|
||||
placeholder="admin@aduanasoft.com"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Input -->
|
||||
<div class="space-y-1.5">
|
||||
<label for="password" class="block text-sm font-semibold text-gray-700">Contraseña</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon name="lock" class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
{#if showPassword}
|
||||
<input
|
||||
id="password"
|
||||
type="text"
|
||||
bind:value={password}
|
||||
on:keydown={handleKeyDown}
|
||||
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
on:keydown={handleKeyDown}
|
||||
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
|
||||
on:click={() => showPassword = !showPassword}
|
||||
>
|
||||
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input id="remember-me" name="remember-me" type="checkbox" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer">
|
||||
<label for="remember-me" class="ml-2 block text-sm text-gray-500 cursor-pointer select-none">Recordar en este equipo</label>
|
||||
</div>
|
||||
<a href="/forgot-password" class="text-sm font-medium text-blue-600 hover:text-blue-500">
|
||||
Olvide mi clave
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- 2FA Input -->
|
||||
<div class="space-y-4 animate-slide-up">
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 text-center">Código de Verificación (2FA)</label>
|
||||
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
bind:value={totpCode}
|
||||
on:keydown={handleKeyDown}
|
||||
class="block w-full pl-10 py-3 text-center tracking-[0.5em] font-mono text-lg border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-3.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-bold text-white bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
|
||||
Procesando...
|
||||
{:else}
|
||||
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center text-xs text-gray-400">
|
||||
© 2026 Aduanasoft. Acceso exclusivo autorizado.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
392
frontend-client/src/routes/profile/+page.svelte
Normal file
392
frontend-client/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let currentPassword = '';
|
||||
let newPassword = '';
|
||||
let confirmPassword = '';
|
||||
let firstName = '';
|
||||
let lastName = '';
|
||||
let isUpdatingProfile = false;
|
||||
let isChangingPassword = false;
|
||||
let profileErrors: Record<string, string> = {};
|
||||
let passwordErrors: Record<string, string> = {};
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if not authenticated
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize form with user data
|
||||
if ($auth.user) {
|
||||
firstName = $auth.user.first_name;
|
||||
lastName = $auth.user.last_name;
|
||||
}
|
||||
});
|
||||
|
||||
function validateProfileForm() {
|
||||
profileErrors = {};
|
||||
|
||||
if (!firstName.trim()) {
|
||||
profileErrors.firstName = 'El nombre es requerido';
|
||||
}
|
||||
|
||||
if (!lastName.trim()) {
|
||||
profileErrors.lastName = 'El apellido es requerido';
|
||||
}
|
||||
|
||||
return Object.keys(profileErrors).length === 0;
|
||||
}
|
||||
|
||||
function validatePasswordForm() {
|
||||
passwordErrors = {};
|
||||
|
||||
if (!currentPassword) {
|
||||
passwordErrors.currentPassword = 'La contraseña actual es requerida';
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
passwordErrors.newPassword = 'La nueva contraseña es requerida';
|
||||
} else if (newPassword.length < 8) {
|
||||
passwordErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres';
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
passwordErrors.confirmPassword = 'Confirma la nueva contraseña';
|
||||
} else if (newPassword !== confirmPassword) {
|
||||
passwordErrors.confirmPassword = 'Las contraseñas no coinciden';
|
||||
}
|
||||
|
||||
return Object.keys(passwordErrors).length === 0;
|
||||
}
|
||||
|
||||
async function handleProfileUpdate() {
|
||||
if (!validateProfileForm()) return;
|
||||
|
||||
isUpdatingProfile = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/profile', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${$auth.token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Error al actualizar perfil');
|
||||
}
|
||||
|
||||
const updatedUser = await response.json();
|
||||
auth.updateUser(updatedUser);
|
||||
toast.success('Perfil actualizado exitosamente');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Error al actualizar perfil');
|
||||
} finally {
|
||||
isUpdatingProfile = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordChange() {
|
||||
if (!validatePasswordForm()) return;
|
||||
|
||||
isChangingPassword = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${$auth.token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Error al cambiar contraseña');
|
||||
}
|
||||
|
||||
// Clear form
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
|
||||
toast.success('Contraseña cambiada exitosamente');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Error al cambiar contraseña');
|
||||
} finally {
|
||||
isChangingPassword = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mi Perfil - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Mi Perfil</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Gestiona tu información personal y configuración de seguridad
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Profile Information -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Información Personal</h2>
|
||||
<p class="text-gray-600 mt-1">Actualiza tu información básica</p>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<form on:submit|preventDefault={handleProfileUpdate} class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="first-name" class="form-label">
|
||||
Nombre <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="first-name"
|
||||
type="text"
|
||||
class="form-input {profileErrors.firstName ? 'border-red-300' : ''}"
|
||||
bind:value={firstName}
|
||||
disabled={isUpdatingProfile}
|
||||
/>
|
||||
{#if profileErrors.firstName}
|
||||
<p class="form-error">{profileErrors.firstName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last-name" class="form-label">
|
||||
Apellido <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="last-name"
|
||||
type="text"
|
||||
class="form-input {profileErrors.lastName ? 'border-red-300' : ''}"
|
||||
bind:value={lastName}
|
||||
disabled={isUpdatingProfile}
|
||||
/>
|
||||
{#if profileErrors.lastName}
|
||||
<p class="form-error">{profileErrors.lastName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="form-label">Correo Electrónico</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
class="form-input bg-gray-50"
|
||||
value={$auth.user?.email || ''}
|
||||
disabled
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
El correo electrónico no se puede cambiar. Contacta con soporte si necesitas actualizarlo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary px-6 py-2"
|
||||
disabled={isUpdatingProfile}
|
||||
>
|
||||
{#if isUpdatingProfile}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="spinner w-4 h-4"></div>
|
||||
<span>Guardando...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Guardar Cambios
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Security -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Seguridad de la Cuenta</h2>
|
||||
<p class="text-gray-600 mt-1">Gestiona tu contraseña y configuración de seguridad</p>
|
||||
</div>
|
||||
|
||||
<div class="card-content space-y-6">
|
||||
<!-- Two-Factor Authentication Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900">Autenticación de dos factores (2FA)</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{$auth.user?.is_two_factor_enabled
|
||||
? 'La autenticación de dos factores está habilitada'
|
||||
: 'Mejora la seguridad habilitando 2FA'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{#if $auth.user?.is_two_factor_enabled}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Habilitado
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Deshabilitado
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Form -->
|
||||
<form on:submit|preventDefault={handlePasswordChange} class="space-y-6">
|
||||
<h3 class="text-lg font-medium text-gray-900">Cambiar Contraseña</h3>
|
||||
|
||||
<div>
|
||||
<label for="current-password" class="form-label">
|
||||
Contraseña Actual <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
class="form-input {passwordErrors.currentPassword ? 'border-red-300' : ''}"
|
||||
bind:value={currentPassword}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
{#if passwordErrors.currentPassword}
|
||||
<p class="form-error">{passwordErrors.currentPassword}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="new-password" class="form-label">
|
||||
Nueva Contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
class="form-input {passwordErrors.newPassword ? 'border-red-300' : ''}"
|
||||
bind:value={newPassword}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
{#if passwordErrors.newPassword}
|
||||
<p class="form-error">{passwordErrors.newPassword}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Mínimo 8 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="form-label">
|
||||
Confirmar Nueva Contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
class="form-input {passwordErrors.confirmPassword ? 'border-red-300' : ''}"
|
||||
bind:value={confirmPassword}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
{#if passwordErrors.confirmPassword}
|
||||
<p class="form-error">{passwordErrors.confirmPassword}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary px-6 py-2"
|
||||
disabled={isChangingPassword}
|
||||
>
|
||||
{#if isChangingPassword}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="spinner w-4 h-4"></div>
|
||||
<span>Cambiando...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Cambiar Contraseña
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Information -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Información de la Cuenta</h2>
|
||||
<p class="text-gray-600 mt-1">Detalles sobre tu cuenta y organización</p>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<dl class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">ID de Usuario</dt>
|
||||
<dd class="text-sm text-gray-900 font-mono mt-1">#{$auth.user?.id.substring(0, 8)}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Rol</dt>
|
||||
<dd class="text-sm text-gray-900 mt-1">
|
||||
{$auth.user?.role === 'CLIENT_ADMIN' ? 'Administrador de Cliente' : 'Usuario de Cliente'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Estado de la Cuenta</dt>
|
||||
<dd class="text-sm mt-1">
|
||||
{#if $auth.user?.is_active}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Activa
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Inactiva
|
||||
</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Miembro desde</dt>
|
||||
<dd class="text-sm text-gray-900 mt-1">
|
||||
{$auth.user?.created_at ? new Date($auth.user.created_at).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}) : 'N/A'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
270
frontend-client/src/routes/tickets/+page.svelte
Normal file
270
frontend-client/src/routes/tickets/+page.svelte
Normal file
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { tickets } from '$lib/stores/tickets.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import TicketCard from '$lib/components/TicketCard.svelte';
|
||||
|
||||
let searchQuery = '';
|
||||
let statusFilter = '';
|
||||
let priorityFilter = '';
|
||||
let filteredTickets: any[] = [];
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if not authenticated
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load tickets
|
||||
tickets.loadTickets();
|
||||
});
|
||||
|
||||
// Filter tickets based on search and filters
|
||||
$: {
|
||||
filteredTickets = $tickets.tickets.filter(ticket => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ticket.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ticket.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStatus = !statusFilter || ticket.status === statusFilter;
|
||||
const matchesPriority = !priorityFilter || ticket.priority === priorityFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesPriority;
|
||||
});
|
||||
}
|
||||
|
||||
// Get status counts
|
||||
$: statusCounts = $tickets.tickets.reduce((acc, ticket) => {
|
||||
acc[ticket.status] = (acc[ticket.status] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery = '';
|
||||
statusFilter = '';
|
||||
priorityFilter = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mis Tickets - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Mis Tickets</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Gestiona y da seguimiento a todos tus tickets de soporte
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/tickets/new"
|
||||
class="btn-primary px-4 py-2 inline-flex items-center space-x-2"
|
||||
>
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<span>Crear Ticket</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-500">Total</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{$tickets.tickets.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-500">En Progreso</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
{statusCounts['IN_PROGRESS'] || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-500">Esperando</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
{statusCounts['WAITING_FOR_CLIENT'] || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-500">Resueltos</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
{(statusCounts['RESOLVED'] || 0) + (statusCounts['CLOSED'] || 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-8">
|
||||
<div class="card-content">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="search" class="form-label">Buscar</label>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="Buscar por título, descripción o ID..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status-filter" class="form-label">Estado</label>
|
||||
<select
|
||||
id="status-filter"
|
||||
class="form-input"
|
||||
bind:value={statusFilter}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="NEW">Nuevo</option>
|
||||
<option value="IN_PROGRESS">En Progreso</option>
|
||||
<option value="WAITING_FOR_CLIENT">Esperando Cliente</option>
|
||||
<option value="RESOLVED">Resuelto</option>
|
||||
<option value="CLOSED">Cerrado</option>
|
||||
<option value="REOPENED">Reabierto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="priority-filter" class="form-label">Prioridad</label>
|
||||
<select
|
||||
id="priority-filter"
|
||||
class="form-input"
|
||||
bind:value={priorityFilter}
|
||||
>
|
||||
<option value="">Todas las prioridades</option>
|
||||
<option value="LOW">Baja</option>
|
||||
<option value="MEDIUM">Media</option>
|
||||
<option value="HIGH">Alta</option>
|
||||
<option value="URGENT">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
on:click={clearFilters}
|
||||
class="btn-secondary px-4 py-2 w-full"
|
||||
>
|
||||
Limpiar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets List -->
|
||||
<div class="space-y-6">
|
||||
{#if $tickets.isLoading}
|
||||
<div class="text-center py-12">
|
||||
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">Cargando tickets...</p>
|
||||
</div>
|
||||
{:else if $tickets.error}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar tickets</h3>
|
||||
<p class="text-gray-600 mb-4">{$tickets.error}</p>
|
||||
<button
|
||||
on:click={() => tickets.loadTickets()}
|
||||
class="btn-primary px-4 py-2"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
{:else if filteredTickets.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
{$tickets.tickets.length === 0 ? 'No tienes tickets' : 'No se encontraron tickets'}
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
{$tickets.tickets.length === 0
|
||||
? 'Crea tu primer ticket para comenzar'
|
||||
: 'Intenta ajustar los filtros de búsqueda'}
|
||||
</p>
|
||||
{#if $tickets.tickets.length === 0}
|
||||
<a href="/tickets/new" class="btn-primary px-4 py-2">
|
||||
Crear Ticket
|
||||
</a>
|
||||
{:else}
|
||||
<button on:click={clearFilters} class="btn-secondary px-4 py-2">
|
||||
Limpiar Filtros
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each filteredTickets as ticket (ticket.id)}
|
||||
<TicketCard {ticket} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filteredTickets.length !== $tickets.tickets.length}
|
||||
<div class="text-center py-4 text-sm text-gray-500">
|
||||
Mostrando {filteredTickets.length} de {$tickets.tickets.length} tickets
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
501
frontend-client/src/routes/tickets/[id]/+page.svelte
Normal file
501
frontend-client/src/routes/tickets/[id]/+page.svelte
Normal file
@@ -0,0 +1,501 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { tickets } from '$lib/stores/tickets.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let ticketId: string;
|
||||
let newComment = '';
|
||||
let isSubmittingComment = false;
|
||||
let isClosingTicket = false;
|
||||
let showCloseDialog = false;
|
||||
let closeResolution = '';
|
||||
let fileInput: HTMLInputElement;
|
||||
let isUploading = false;
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if not authenticated
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
ticketId = $page.params.id;
|
||||
if (ticketId) {
|
||||
tickets.loadTicket(ticketId);
|
||||
}
|
||||
});
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Status mapping
|
||||
const statusConfig = {
|
||||
NEW: { label: 'Nuevo', class: 'badge-new' },
|
||||
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
|
||||
WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
|
||||
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
|
||||
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
|
||||
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
|
||||
};
|
||||
|
||||
// Priority mapping
|
||||
const priorityConfig = {
|
||||
LOW: { label: 'Baja', class: 'badge-priority-low' },
|
||||
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
|
||||
HIGH: { label: 'Alta', class: 'badge-priority-high' },
|
||||
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
|
||||
};
|
||||
|
||||
async function handleAddComment() {
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
isSubmittingComment = true;
|
||||
try {
|
||||
await tickets.addComment(ticketId, newComment.trim());
|
||||
newComment = '';
|
||||
toast.success('Comentario agregado');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Error al agregar comentario');
|
||||
} finally {
|
||||
isSubmittingComment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('El archivo es demasiado grande. Máximo 10MB');
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf', 'text/plain', 'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error('Tipo de archivo no permitido');
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
try {
|
||||
await tickets.uploadAttachment(ticketId, file);
|
||||
toast.success('Archivo adjuntado correctamente');
|
||||
target.value = '';
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Error al subir archivo');
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseTicket() {
|
||||
showCloseDialog = true;
|
||||
}
|
||||
|
||||
async function confirmCloseTicket() {
|
||||
isClosingTicket = true;
|
||||
try {
|
||||
await tickets.closeTicket(ticketId, closeResolution.trim() || undefined);
|
||||
showCloseDialog = false;
|
||||
closeResolution = '';
|
||||
toast.success('Ticket cerrado exitosamente');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Error al cerrar ticket');
|
||||
} finally {
|
||||
isClosingTicket = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelCloseTicket() {
|
||||
showCloseDialog = false;
|
||||
closeResolution = '';
|
||||
}
|
||||
|
||||
// Check if user can close ticket
|
||||
$: canClose = $tickets.currentTicket &&
|
||||
['RESOLVED', 'WAITING_FOR_CLIENT'].includes($tickets.currentTicket.status);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{$tickets.currentTicket ? `Ticket: ${$tickets.currentTicket.title}` : 'Cargando...'} - ServiceManager
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{#if $tickets.isLoading}
|
||||
<div class="text-center py-12">
|
||||
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">Cargando ticket...</p>
|
||||
</div>
|
||||
{:else if $tickets.error}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar ticket</h3>
|
||||
<p class="text-gray-600 mb-4">{$tickets.error}</p>
|
||||
<button
|
||||
on:click={() => tickets.loadTicket(ticketId)}
|
||||
class="btn-primary px-4 py-2"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
{:else if $tickets.currentTicket}
|
||||
<!-- Breadcrumb -->
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-6">
|
||||
<a href="/tickets" class="hover:text-primary-600">Mis Tickets</a>
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>#{$tickets.currentTicket.id.substring(0, 8)}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Ticket Header -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">
|
||||
{$tickets.currentTicket.title}
|
||||
</h1>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class={statusConfig[$tickets.currentTicket.status].class}>
|
||||
{statusConfig[$tickets.currentTicket.status].label}
|
||||
</span>
|
||||
<span class={priorityConfig[$tickets.currentTicket.priority].class}>
|
||||
{priorityConfig[$tickets.currentTicket.priority].label}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
Creado {formatDate($tickets.currentTicket.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if canClose}
|
||||
<button
|
||||
on:click={handleCloseTicket}
|
||||
class="btn-success px-4 py-2"
|
||||
disabled={isClosingTicket}
|
||||
>
|
||||
Cerrar Ticket
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="prose max-w-none">
|
||||
<p class="whitespace-pre-wrap text-gray-700">
|
||||
{$tickets.currentTicket.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if $tickets.currentTicket.resolution}
|
||||
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h4 class="font-medium text-green-900 mb-2">Resolución:</h4>
|
||||
<p class="text-green-800 whitespace-pre-wrap">
|
||||
{$tickets.currentTicket.resolution}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
{#if $tickets.attachments.length > 0}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Archivos Adjuntos</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="space-y-3">
|
||||
{#each $tickets.attachments as attachment}
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{attachment.original_filename}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{Math.round(attachment.size_bytes / 1024)} KB •
|
||||
Subido por {attachment.uploaded_by_name} •
|
||||
{formatDate(attachment.uploaded_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/api/v1/tickets/{ticketId}/attachments/{attachment.id}/download"
|
||||
class="btn-ghost p-2"
|
||||
target="_blank"
|
||||
>
|
||||
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Conversación</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{#if $tickets.comments.length === 0}
|
||||
<p class="text-gray-500 text-center py-4">
|
||||
No hay comentarios aún. ¡Sé el primero en comentar!
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each $tickets.comments as comment}
|
||||
<div class="flex space-x-3">
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-primary-600 text-xs font-medium">
|
||||
{comment.user_name.split(' ').map(n => n[0]).join('')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="text-sm font-medium text-gray-900">
|
||||
{comment.user_name}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{formatDate(comment.created_at)}
|
||||
</span>
|
||||
{#if comment.is_internal}
|
||||
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">
|
||||
Interno
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add Comment Form -->
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="space-y-4">
|
||||
<textarea
|
||||
rows="4"
|
||||
class="form-input"
|
||||
placeholder="Escribe tu comentario o respuesta..."
|
||||
bind:value={newComment}
|
||||
disabled={isSubmittingComment}
|
||||
></textarea>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<input
|
||||
type="file"
|
||||
bind:this={fileInput}
|
||||
on:change={handleFileUpload}
|
||||
class="hidden"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => fileInput.click()}
|
||||
class="btn-ghost p-2 flex items-center space-x-2"
|
||||
disabled={isUploading}
|
||||
>
|
||||
{#if isUploading}
|
||||
<div class="spinner w-4 h-4"></div>
|
||||
{:else}
|
||||
<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="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="text-sm">Adjuntar archivo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={handleAddComment}
|
||||
class="btn-primary px-4 py-2"
|
||||
disabled={isSubmittingComment || !newComment.trim()}
|
||||
>
|
||||
{#if isSubmittingComment}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="spinner w-4 h-4"></div>
|
||||
<span>Enviando...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Enviar Comentario
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Ticket Info -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Información</h3>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
|
||||
<dd class="text-sm text-gray-900 font-mono">#{$tickets.currentTicket.id.substring(0, 8)}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Categoría</dt>
|
||||
<dd class="text-sm text-gray-900">{$tickets.currentTicket.category_name || 'Sin categoría'}</dd>
|
||||
</div>
|
||||
|
||||
{#if $tickets.currentTicket.assigned_to_name}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Asignado a</dt>
|
||||
<dd class="text-sm text-gray-900">{$tickets.currentTicket.assigned_to_name}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Creado</dt>
|
||||
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.created_at)}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Última actualización</dt>
|
||||
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.updated_at)}</dd>
|
||||
</div>
|
||||
|
||||
{#if $tickets.currentTicket.due_date}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt>
|
||||
<dd class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) < new Date() ? 'text-red-600' : ''}">
|
||||
{formatDate($tickets.currentTicket.due_date)}
|
||||
{#if new Date($tickets.currentTicket.due_date) < new Date()}
|
||||
<span class="block text-xs text-red-500">¡Vencido!</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Close Ticket Dialog -->
|
||||
{#if showCloseDialog}
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 transition-opacity" on:click={cancelCloseTicket}>
|
||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
||||
</div>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Cerrar Ticket
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el problema ha sido resuelto satisfactoriamente.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="close-resolution" class="form-label">
|
||||
Comentario de cierre (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
id="close-resolution"
|
||||
rows="3"
|
||||
class="form-input"
|
||||
placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..."
|
||||
bind:value={closeResolution}
|
||||
disabled={isClosingTicket}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full inline-flex justify-center btn-success px-4 py-2 sm:ml-3 sm:w-auto disabled:opacity-50"
|
||||
disabled={isClosingTicket}
|
||||
on:click={confirmCloseTicket}
|
||||
>
|
||||
{#if isClosingTicket}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="spinner w-4 h-4"></div>
|
||||
<span>Cerrando...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Cerrar Ticket
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 w-full inline-flex justify-center btn-secondary px-4 py-2 sm:mt-0 sm:w-auto"
|
||||
disabled={isClosingTicket}
|
||||
on:click={cancelCloseTicket}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
241
frontend-client/src/routes/tickets/new/+page.svelte
Normal file
241
frontend-client/src/routes/tickets/new/+page.svelte
Normal file
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { tickets } from '$lib/stores/tickets.js';
|
||||
import { app } from '$lib/stores/app.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let title = '';
|
||||
let description = '';
|
||||
let categoryId = '';
|
||||
let priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT' = 'MEDIUM';
|
||||
let isSubmitting = false;
|
||||
let errors: Record<string, string> = {};
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if not authenticated
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load categories for the form
|
||||
app.loadCategories();
|
||||
});
|
||||
|
||||
function validateForm() {
|
||||
errors = {};
|
||||
|
||||
if (!title.trim()) {
|
||||
errors.title = 'El título es requerido';
|
||||
} else if (title.trim().length < 10) {
|
||||
errors.title = 'El título debe tener al menos 10 caracteres';
|
||||
}
|
||||
|
||||
if (!description.trim()) {
|
||||
errors.description = 'La descripción es requerida';
|
||||
} else if (description.trim().length < 20) {
|
||||
errors.description = 'La descripción debe tener al menos 20 caracteres';
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
errors.categoryId = 'Debes seleccionar una categoría';
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validateForm()) return;
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const newTicket = await tickets.createTicket({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
category_id: categoryId,
|
||||
priority
|
||||
});
|
||||
|
||||
toast.success('Ticket creado exitosamente');
|
||||
goto(`/tickets/${newTicket.id}`);
|
||||
} catch (error: any) {
|
||||
console.error('Create ticket error:', error);
|
||||
toast.error(error.message || 'Error al crear el ticket');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Crear Ticket - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-4">
|
||||
<a href="/tickets" class="hover:text-primary-600">Mis Tickets</a>
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>Crear Ticket</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-900">Crear Nuevo Ticket</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Describe tu problema o solicitud de soporte con el mayor detalle posible
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-6">
|
||||
<div class="card">
|
||||
<div class="card-content space-y-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="form-label">
|
||||
Título del Ticket <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
class="form-input {errors.title ? 'border-red-300' : ''}"
|
||||
placeholder="Describe brevemente el problema..."
|
||||
bind:value={title}
|
||||
disabled={isSubmitting}
|
||||
maxlength="200"
|
||||
/>
|
||||
{#if errors.title}
|
||||
<p class="form-error">{errors.title}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{title.length}/200 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label for="category" class="form-label">
|
||||
Categoría <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
class="form-input {errors.categoryId ? 'border-red-300' : ''}"
|
||||
bind:value={categoryId}
|
||||
disabled={isSubmitting || $app.isLoading}
|
||||
>
|
||||
<option value="">Selecciona una categoría</option>
|
||||
{#each $app.categories as category}
|
||||
<option value={category.id}>{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if errors.categoryId}
|
||||
<p class="form-error">{errors.categoryId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<label for="priority" class="form-label">
|
||||
Prioridad
|
||||
</label>
|
||||
<select
|
||||
id="priority"
|
||||
class="form-input"
|
||||
bind:value={priority}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="LOW">Baja - No es urgente, puede esperar</option>
|
||||
<option value="MEDIUM">Media - Problema normal de trabajo</option>
|
||||
<option value="HIGH">Alta - Afecta el trabajo significativamente</option>
|
||||
<option value="URGENT">Urgente - Bloquea el trabajo completamente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="form-label">
|
||||
Descripción del Problema <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows="8"
|
||||
class="form-input {errors.description ? 'border-red-300' : ''}"
|
||||
placeholder="Describe el problema con el mayor detalle posible. Incluye:
|
||||
- Qué estabas haciendo cuando ocurrió el problema
|
||||
- Qué esperabas que pasara
|
||||
- Qué pasó en realidad
|
||||
- Pasos para reproducir el problema
|
||||
- Cualquier mensaje de error
|
||||
- Información adicional relevante"
|
||||
bind:value={description}
|
||||
disabled={isSubmitting}
|
||||
maxlength="2000"
|
||||
></textarea>
|
||||
{#if errors.description}
|
||||
<p class="form-error">{errors.description}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{description.length}/2000 caracteres
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Tips -->
|
||||
<div class="card bg-blue-50 border-blue-200">
|
||||
<div class="card-content">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">
|
||||
Tips para un mejor soporte
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Sé específico y detallado en tu descripción</li>
|
||||
<li>Incluye capturas de pantalla si es posible (puedes adjuntarlas después)</li>
|
||||
<li>Menciona qué navegador/sistema operativo estás usando</li>
|
||||
<li>Indica si el problema es recurrente o fue la primera vez</li>
|
||||
<li>Si hay mensajes de error, cópialos exactamente</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center pt-6">
|
||||
<a
|
||||
href="/tickets"
|
||||
class="btn-secondary px-6 py-2"
|
||||
>
|
||||
Cancelar
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary px-6 py-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="spinner w-4 h-4"></div>
|
||||
<span>Creando...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Crear Ticket
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
BIN
frontend-client/static/images/Icono AS(1).png
Normal file
BIN
frontend-client/static/images/Icono AS(1).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend-client/static/images/Logo AS 192px -192px(1).png
Normal file
BIN
frontend-client/static/images/Logo AS 192px -192px(1).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend-client/static/images/Logo AS 512px - 512px(1).png
Normal file
BIN
frontend-client/static/images/Logo AS 512px - 512px(1).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend-client/static/images/Logo AS blanco(1).png
Normal file
BIN
frontend-client/static/images/Logo AS blanco(1).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
BIN
frontend-client/static/images/SOPORTE.webp
Normal file
BIN
frontend-client/static/images/SOPORTE.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
25
frontend-client/svelte.config.js
Normal file
25
frontend-client/svelte.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/kit/vite';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
|
||||
alias: {
|
||||
'$components': 'src/lib/components',
|
||||
'$stores': 'src/lib/stores',
|
||||
'$utils': 'src/lib/utils',
|
||||
'$types': 'src/lib/types'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
101
frontend-client/tailwind.config.js
Normal file
101
frontend-client/tailwind.config.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Brand colors for ServiceManager
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6', // Main brand color
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
'DEFAULT': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
'xl': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
'2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/typography'),
|
||||
],
|
||||
};
|
||||
24
frontend-client/vite.config.js
Normal file
24
frontend-client/vite.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://servicemanager-backend:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext'
|
||||
}
|
||||
});
|
||||
42
frontend-internal/.eslintrc.json
Normal file
42
frontend-internal/.eslintrc.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "svelte3"],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2017": true,
|
||||
"node": true
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.svelte"],
|
||||
"processor": "svelte3/svelte3"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ "argsIgnorePattern": "^_" }
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }],
|
||||
"prefer-const": "error"
|
||||
},
|
||||
"settings": {
|
||||
"svelte3/typescript": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
".svelte-kit/**",
|
||||
"build/**",
|
||||
"dist/**",
|
||||
"node_modules/**"
|
||||
]
|
||||
}
|
||||
11
frontend-internal/.prettierignore
Normal file
11
frontend-internal/.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.svelte-kit
|
||||
build
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
.DS_Store
|
||||
*.log
|
||||
coverage
|
||||
.nyc_output
|
||||
22
frontend-internal/.prettierrc
Normal file
22
frontend-internal/.prettierrc
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"quoteProps": "as-needed",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
49
frontend-internal/package.json
Normal file
49
frontend-internal/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@servicemanager/internal-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000 --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",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.20.4",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte": "^2.30.0",
|
||||
"postcss": "^8.4.24",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.10.1",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-check": "^3.4.3",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.4.2",
|
||||
"vitest": "^0.34.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/forms": "^0.5.4",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"heroicons": "^2.0.18",
|
||||
"zod": "^3.22.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"chart.js": "^4.3.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0"
|
||||
}
|
||||
}
|
||||
6
frontend-internal/postcss.config.js
Normal file
6
frontend-internal/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
225
frontend-internal/src/app.css
Normal file
225
frontend-internal/src/app.css
Normal file
@@ -0,0 +1,225 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom base styles */
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-gray-900 bg-gray-50;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus {
|
||||
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
|
||||
}
|
||||
|
||||
/* Selection styles */
|
||||
::selection {
|
||||
@apply bg-primary-100 text-primary-900;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom component styles */
|
||||
@layer components {
|
||||
/* Card component */
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none px-4 py-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 active:bg-secondary-300;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-success-600 text-white hover:bg-success-700 active:bg-success-800;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply bg-warning-600 text-white hover:bg-warning-700 active:bg-warning-800;
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
@apply bg-error-600 text-white hover:bg-error-700 active:bg-error-800;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply bg-transparent hover:bg-secondary-100 active:bg-secondary-200;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply p-6 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@apply p-6 border-t border-gray-200 bg-gray-50 rounded-b-lg;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.form-input {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply text-sm text-error-600 mt-1;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
@apply badge bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.badge-in-progress {
|
||||
@apply badge bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.badge-waiting {
|
||||
@apply badge bg-orange-100 text-orange-800;
|
||||
}
|
||||
|
||||
.badge-resolved {
|
||||
@apply badge bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.badge-closed {
|
||||
@apply badge bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
.badge-reopened {
|
||||
@apply badge bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
/* Priority badges */
|
||||
.badge-priority-low {
|
||||
@apply badge bg-gray-100 text-gray-600;
|
||||
}
|
||||
|
||||
.badge-priority-medium {
|
||||
@apply badge bg-blue-100 text-blue-700;
|
||||
}
|
||||
|
||||
.badge-priority-high {
|
||||
@apply badge bg-orange-100 text-orange-700;
|
||||
}
|
||||
|
||||
.badge-priority-urgent {
|
||||
@apply badge bg-red-100 text-red-700;
|
||||
}
|
||||
|
||||
/* Role badges */
|
||||
.badge-admin {
|
||||
@apply badge bg-purple-100 text-purple-800;
|
||||
}
|
||||
|
||||
.badge-support-manager {
|
||||
@apply badge bg-indigo-100 text-indigo-800;
|
||||
}
|
||||
|
||||
.badge-agent {
|
||||
@apply badge bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.badge-auditor {
|
||||
@apply badge bg-gray-100 text-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom utility classes */
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom keyframes */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
16
frontend-internal/src/app.html
Normal file
16
frontend-internal/src/app.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
89
frontend-internal/src/lib/components/Header.svelte
Normal file
89
frontend-internal/src/lib/components/Header.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
|
||||
export let toggleSidebar: () => void;
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout();
|
||||
}
|
||||
|
||||
let isMenuOpen = false;
|
||||
|
||||
function toggleMenu() {
|
||||
isMenuOpen = !isMenuOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<!-- Left side -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button
|
||||
on:click={toggleSidebar}
|
||||
class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 lg:hidden"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="hidden lg:block">
|
||||
<h1 class="text-lg font-semibold text-gray-900">ServiceManager Internal</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- User menu -->
|
||||
<div class="relative">
|
||||
<button
|
||||
on:click={toggleMenu}
|
||||
class="flex items-center space-x-2 text-gray-700 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-md p-2"
|
||||
>
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-primary-600 text-sm font-medium">
|
||||
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<span class="hidden sm:block text-sm">
|
||||
{$auth.user?.first_name} {$auth.user?.last_name}
|
||||
</span>
|
||||
<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="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isMenuOpen}
|
||||
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50">
|
||||
<div class="py-1">
|
||||
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
|
||||
{$auth.user?.email}
|
||||
</div>
|
||||
<a
|
||||
href="/profile"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
on:click={() => isMenuOpen = false}
|
||||
>
|
||||
Mi Perfil
|
||||
</a>
|
||||
<button
|
||||
on:click={handleLogout}
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Backdrop for mobile menu -->
|
||||
{#if isMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40 lg:hidden"
|
||||
on:click={() => isMenuOpen = false}
|
||||
></div>
|
||||
{/if}
|
||||
43
frontend-internal/src/lib/components/Icon.svelte
Normal file
43
frontend-internal/src/lib/components/Icon.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
export let size: string = 'w-5 h-5';
|
||||
export let className: string = '';
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
||||
ticket: 'M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z',
|
||||
plus: 'M12 4v16m8-8H4',
|
||||
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||
x: 'M6 18L18 6M6 6l12 12',
|
||||
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
|
||||
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||
edit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
|
||||
trash: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
|
||||
eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z',
|
||||
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
check: 'M5 13l4 4L19 7',
|
||||
chevronDown: 'M19 9l-7 7-7-7',
|
||||
dashboard: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z',
|
||||
users: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z',
|
||||
chart: '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',
|
||||
settings: '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 M15 12a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
};
|
||||
|
||||
$: path = icons[name] || icons.home;
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="{size} {className}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={path}
|
||||
/>
|
||||
</svg>
|
||||
44
frontend-internal/src/lib/components/Modal.svelte
Normal file
44
frontend-internal/src/lib/components/Modal.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
|
||||
export let open = false;
|
||||
export let title = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape' && open) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown}/>
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
|
||||
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
153
frontend-internal/src/lib/components/Sidebar.svelte
Normal file
153
frontend-internal/src/lib/components/Sidebar.svelte
Normal file
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let open = false;
|
||||
|
||||
// Navigation items based on user role
|
||||
$: navigation = getNavigationForRole($auth.user?.role);
|
||||
|
||||
function getNavigationForRole(role: string | undefined) {
|
||||
const baseNavigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/',
|
||||
icon: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z'
|
||||
},
|
||||
{
|
||||
name: 'Tickets',
|
||||
href: '/tickets',
|
||||
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'
|
||||
}
|
||||
];
|
||||
|
||||
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
|
||||
baseNavigation.push(
|
||||
{
|
||||
name: 'Clientes',
|
||||
href: '/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'
|
||||
},
|
||||
{
|
||||
name: 'Usuarios',
|
||||
href: '/users',
|
||||
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z'
|
||||
},
|
||||
{
|
||||
name: 'Categorías',
|
||||
href: '/categories',
|
||||
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'
|
||||
},
|
||||
{
|
||||
name: 'Sistemas',
|
||||
href: '/systems',
|
||||
icon: 'M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zm14 0a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L19 17.586V12z'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (role === 'ADMIN') {
|
||||
baseNavigation.push(
|
||||
{
|
||||
name: 'SLA Management',
|
||||
href: '/sla',
|
||||
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
},
|
||||
{
|
||||
name: 'Reportes',
|
||||
href: '/reports',
|
||||
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'
|
||||
},
|
||||
{
|
||||
name: 'Auditoría',
|
||||
href: '/audit',
|
||||
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'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return baseNavigation;
|
||||
}
|
||||
|
||||
function isCurrentPage(href: string) {
|
||||
return $page.url.pathname === href ||
|
||||
($page.url.pathname.startsWith(href) && href !== '/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile sidebar backdrop -->
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-40 lg:hidden">
|
||||
<div class="fixed inset-0 bg-gray-600 bg-opacity-75" on:click={() => open = false}></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform {open ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-between h-16 px-6 bg-primary-600">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-white">
|
||||
<h1 class="text-sm font-semibold">ServiceManager</h1>
|
||||
<p class="text-xs text-primary-100">Panel Interno</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={() => open = false}
|
||||
class="p-2 rounded-md text-primary-100 hover:text-white hover:bg-primary-500 lg:hidden"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-4 py-6 space-y-2">
|
||||
{#each navigation as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors {
|
||||
isCurrentPage(item.href)
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}"
|
||||
on:click={() => open = false}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
|
||||
</svg>
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- User info -->
|
||||
<div class="px-4 py-4 border-t border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-primary-600 text-sm font-medium">
|
||||
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
{$auth.user?.first_name} {$auth.user?.last_name}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 truncate">
|
||||
{$auth.user?.role === 'ADMIN' ? 'Administrador' :
|
||||
$auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' :
|
||||
$auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
117
frontend-internal/src/lib/components/Toast.svelte
Normal file
117
frontend-internal/src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
export let type: 'success' | 'error' | 'warning' | 'info' = 'info';
|
||||
export let message: string;
|
||||
export let duration: number = 5000;
|
||||
export let dismissible: boolean = true;
|
||||
|
||||
let visible = true;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
// Auto-dismiss after duration
|
||||
if (duration > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
visible = false;
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
visible = false;
|
||||
}
|
||||
|
||||
// Cleanup timeout on destroy
|
||||
import { onDestroy } from 'svelte';
|
||||
onDestroy(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
// Style mapping
|
||||
const typeStyles = {
|
||||
success: {
|
||||
container: 'bg-success-50 border-success-200 text-success-800',
|
||||
icon: 'text-success-400'
|
||||
},
|
||||
error: {
|
||||
container: 'bg-error-50 border-error-200 text-error-800',
|
||||
icon: 'text-error-400'
|
||||
},
|
||||
warning: {
|
||||
container: 'bg-warning-50 border-warning-200 text-warning-800',
|
||||
icon: 'text-warning-400'
|
||||
},
|
||||
info: {
|
||||
container: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
icon: 'text-blue-400'
|
||||
}
|
||||
};
|
||||
|
||||
// Icon mapping
|
||||
const typeIcons = {
|
||||
success: 'M5 13l4 4L19 7',
|
||||
error: 'M6 18L18 6M6 6l12 12',
|
||||
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z',
|
||||
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="fixed top-4 right-4 max-w-sm w-full z-50 animate-slide-up">
|
||||
<div class="rounded-lg border p-4 shadow-lg {typeStyles[type].container}">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 {typeStyles[type].icon}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={typeIcons[type]}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if dismissible}
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 {typeStyles[type].icon} hover:opacity-75"
|
||||
on:click={dismiss}
|
||||
>
|
||||
<span class="sr-only">Cerrar</span>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
215
frontend-internal/src/lib/stores/auth.ts
Normal file
215
frontend-internal/src/lib/stores/auth.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface InternalUser {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: 'ADMIN' | 'SUPPORT_MANAGER' | 'AGENT' | 'AUDITOR';
|
||||
is_active: boolean;
|
||||
is_two_factor_enabled: boolean;
|
||||
created_at: string;
|
||||
tenant_id: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: InternalUser | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
tenant_slug: string;
|
||||
totp_code?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: InternalUser;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
};
|
||||
|
||||
// Create auth store
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable<AuthState>(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize auth from localStorage
|
||||
init: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('internal_auth_token');
|
||||
const refreshToken = localStorage.getItem('internal_auth_refresh_token');
|
||||
const user = localStorage.getItem('internal_auth_user');
|
||||
|
||||
if (token && user) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(user);
|
||||
set({
|
||||
user: parsedUser,
|
||||
token,
|
||||
refreshToken: refreshToken || null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored auth data:', error);
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_refresh_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Login
|
||||
login: async (credentials: LoginRequest): Promise<void> => {
|
||||
update(state => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
|
||||
// Store auth data
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('internal_auth_token', data.access_token);
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('internal_auth_refresh_token', data.refresh_token);
|
||||
}
|
||||
localStorage.setItem('internal_auth_user', JSON.stringify(data.user));
|
||||
}
|
||||
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Refresh Session
|
||||
refreshSession: async (): Promise<void> => {
|
||||
// Need to get current state to access refresh token, logic simplified
|
||||
let currentRefreshToken: string | null = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
currentRefreshToken = localStorage.getItem('internal_auth_refresh_token');
|
||||
}
|
||||
|
||||
if (!currentRefreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
update (state => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: currentRefreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If refresh fails, logout
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
auth.logout();
|
||||
}
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Refresh failed');
|
||||
}
|
||||
|
||||
const data: TokenResponse = await response.json();
|
||||
|
||||
// Update token in storage and state
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('internal_auth_token', data.access_token);
|
||||
}
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
token: data.access_token,
|
||||
isLoading: false
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_refresh_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
}
|
||||
set(initialState);
|
||||
// Optional: Redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
},
|
||||
|
||||
// Update user data
|
||||
updateUser: (user: InternalUser) => {
|
||||
update(state => ({ ...state, user }));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('internal_auth_user', JSON.stringify(user));
|
||||
}
|
||||
},
|
||||
|
||||
// Set loading state
|
||||
setLoading: (isLoading: boolean) => {
|
||||
update(state => ({ ...state, isLoading }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuthStore();
|
||||
75
frontend-internal/src/lib/stores/toast.ts
Normal file
75
frontend-internal/src/lib/stores/toast.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Toast notification store
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: ToastMessage[];
|
||||
}
|
||||
|
||||
const initialState: ToastState = {
|
||||
toasts: []
|
||||
};
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update }: Writable<ToastState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
show: (type: ToastMessage['type'], message: string, duration = 5000) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const toast: ToastMessage = { id, type, message, duration };
|
||||
|
||||
update(state => ({
|
||||
toasts: [...state.toasts, toast]
|
||||
}));
|
||||
|
||||
// Auto-remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
update(state => ({
|
||||
toasts: state.toasts.filter(t => t.id !== id)
|
||||
}));
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
dismiss: (id: string) => {
|
||||
update(state => ({
|
||||
toasts: state.toasts.filter(t => t.id !== id)
|
||||
}));
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
update(() => initialState);
|
||||
},
|
||||
|
||||
// Convenience methods
|
||||
success: (message: string, duration?: number) => {
|
||||
return createToastStore().show('success', message, duration);
|
||||
},
|
||||
|
||||
error: (message: string, duration?: number) => {
|
||||
return createToastStore().show('error', message, duration);
|
||||
},
|
||||
|
||||
warning: (message: string, duration?: number) => {
|
||||
return createToastStore().show('warning', message, duration);
|
||||
},
|
||||
|
||||
info: (message: string, duration?: number) => {
|
||||
return createToastStore().show('info', message, duration);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
73
frontend-internal/src/lib/utils/api.ts
Normal file
73
frontend-internal/src/lib/utils/api.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, ...init } = options;
|
||||
|
||||
let url = `${API_BASE}${endpoint}`;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const authState = get(auth);
|
||||
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired or invalid
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Handle empty responses (like 204 No Content)
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(endpoint: string, params?: Record<string, string>) =>
|
||||
request<T>(endpoint, { method: 'GET', params }),
|
||||
|
||||
post: <T>(endpoint: string, body: any) =>
|
||||
request<T>(endpoint, { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
put: <T>(endpoint: string, body: any) =>
|
||||
request<T>(endpoint, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
|
||||
patch: <T>(endpoint: string, body: any) =>
|
||||
request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
|
||||
delete: <T>(endpoint: string) =>
|
||||
request<T>(endpoint, { method: 'DELETE' })
|
||||
};
|
||||
53
frontend-internal/src/routes/+layout.svelte
Normal file
53
frontend-internal/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import '../app.css';
|
||||
|
||||
let sidebarOpen = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
});
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
{#if $auth.isAuthenticated}
|
||||
<!-- Internal Layout with Sidebar -->
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<Sidebar bind:open={sidebarOpen} />
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<Header {toggleSidebar} />
|
||||
|
||||
<main class="flex-1 overflow-auto">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Login Layout -->
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<!-- Toast notifications -->
|
||||
{#each $toast.toasts as toastMessage (toastMessage.id)}
|
||||
<Toast
|
||||
type={toastMessage.type}
|
||||
message={toastMessage.message}
|
||||
duration={toastMessage.duration}
|
||||
on:dismiss={() => toast.dismiss(toastMessage.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
98
frontend-internal/src/routes/+page.svelte
Normal file
98
frontend-internal/src/routes/+page.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Clientes',
|
||||
description: 'Gestión de organizaciones y tenants',
|
||||
icon: 'users',
|
||||
href: '/tenants',
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
title: 'Usuarios',
|
||||
description: 'Administración de usuarios y roles',
|
||||
icon: 'user-plus',
|
||||
href: '/users',
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
title: 'Sistemas',
|
||||
description: 'Catálogo de sistemas soportados',
|
||||
icon: 'server',
|
||||
href: '/systems',
|
||||
color: 'bg-purple-500'
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
description: 'Clasificación de tickets',
|
||||
icon: 'tag',
|
||||
href: '/categories',
|
||||
color: 'bg-orange-500'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard Admin - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<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.
|
||||
</p>
|
||||
</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">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="{card.color} rounded-md p-3">
|
||||
<!-- Simple SVG Icon placeholder since Icon component might expect specific names that map to SVGs -->
|
||||
<svg class="h-6 w-6 text-white" 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-cyan-700 hover:text-cyan-900">
|
||||
Ver detalles
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
184
frontend-internal/src/routes/categories/+page.svelte
Normal file
184
frontend-internal/src/routes/categories/+page.svelte
Normal file
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
let categories = [];
|
||||
let tenants = [];
|
||||
let isLoading = false;
|
||||
let showModal = false;
|
||||
let editingCategory = null;
|
||||
|
||||
let formData = {
|
||||
name: '',
|
||||
description: '',
|
||||
tenant_id: '',
|
||||
is_active: true
|
||||
};
|
||||
|
||||
async function loadData() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const [categoriesData, tenantsData] = await Promise.all([
|
||||
api.get('/categories/'),
|
||||
api.get('/tenants/')
|
||||
]);
|
||||
categories = categoriesData;
|
||||
tenants = tenantsData;
|
||||
} catch (e) {
|
||||
toast.error('Error cargando datos');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingCategory = null;
|
||||
formData = { name: '', description: '', tenant_id: '', is_active: true };
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(category) {
|
||||
editingCategory = category;
|
||||
formData = {
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
tenant_id: category.tenant_id || '',
|
||||
is_active: category.is_active
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
const payload = { ...formData };
|
||||
if (!payload.tenant_id) payload.tenant_id = null;
|
||||
|
||||
if (editingCategory) {
|
||||
await api.put(`/categories/${editingCategory.id}`, payload);
|
||||
toast.success('Categoría actualizada');
|
||||
} else {
|
||||
await api.post('/categories/', payload);
|
||||
toast.success('Categoría creada');
|
||||
}
|
||||
showModal = false;
|
||||
loadData();
|
||||
} catch (e) {
|
||||
toast.error(e.message || 'Error guardando categoría');
|
||||
}
|
||||
}
|
||||
|
||||
function getTenantName(id) {
|
||||
if (!id) return 'Global';
|
||||
const t = tenants.find(t => t.id === id);
|
||||
return t ? t.name : id;
|
||||
}
|
||||
|
||||
onMount(loadData);
|
||||
</script>
|
||||
|
||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Categorías de Tickets</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">Gestión de categorías para clasificación de tickets.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
|
||||
>
|
||||
Nueva Categoría
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Descripción</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Tipo (Cliente)</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">Acciones</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#if isLoading}
|
||||
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
|
||||
{:else if categories.length === 0}
|
||||
<tr><td colspan="5" class="text-center py-4">No hay categorías registradas</td></tr>
|
||||
{:else}
|
||||
{#each categories as category}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{category.name}</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{category.description || '-'}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-blue-100={!category.tenant_id} class:text-blue-800={!category.tenant_id} class:bg-gray-100={category.tenant_id} class:text-gray-800={category.tenant_id} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
{getTenantName(category.tenant_id)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-green-100={category.is_active} class:text-green-800={category.is_active} class:bg-red-100={!category.is_active} class:text-red-800={!category.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
{category.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button on:click={() => openEditModal(category)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={showModal} title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
|
||||
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - Específico para un cliente)</label>
|
||||
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<option value="">-- Global (Para todos) --</option>
|
||||
{#each tenants as tenant}
|
||||
<option value={tenant.id}>{tenant.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
|
||||
Guardar
|
||||
</button>
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
211
frontend-internal/src/routes/login/+page.svelte
Normal file
211
frontend-internal/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let totpCode = '';
|
||||
let isLoading = false;
|
||||
let showTwoFactor = false;
|
||||
let errorMessage = '';
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if already authenticated
|
||||
if ($auth.isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
if (!email || !password) {
|
||||
errorMessage = 'Por favor completa todos los campos';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
|
||||
try {
|
||||
await auth.login({
|
||||
email,
|
||||
password,
|
||||
tenant_slug: 'system-admin',
|
||||
totp_code: totpCode || undefined
|
||||
});
|
||||
|
||||
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
||||
goto('/');
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
// Check if 2FA is required
|
||||
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
||||
showTwoFactor = true;
|
||||
errorMessage = 'Introduce el código de tu aplicación de autenticación';
|
||||
} else {
|
||||
errorMessage = error.message || 'Error al iniciar sesión';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleLogin();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<svelte:head>
|
||||
<title>Acceso Admin - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans">
|
||||
<div class="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
|
||||
<!-- Left Side: Internal Branding -->
|
||||
<div class="hidden md:flex flex-col justify-between p-12 bg-gray-900 text-white relative overflow-hidden">
|
||||
<!-- Grid pattern overlay -->
|
||||
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(white 1px, transparent 1px); background-size: 30px 30px;"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
|
||||
<Icon name="server" class="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold tracking-tight mb-4">
|
||||
Panel de Administración
|
||||
</h1>
|
||||
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
|
||||
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mt-12">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
||||
<Icon name="check-circle" class="w-4 h-4 text-green-500" />
|
||||
<span>System Status: Operational</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
||||
<Icon name="shield" class="w-4 h-4 text-blue-500" />
|
||||
<span>256-bit Encryption Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Login Form -->
|
||||
<div class="p-8 md:p-12 flex flex-col justify-center">
|
||||
|
||||
<div class="max-w-sm mx-auto w-full">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Identifíquese</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={handleLogin} class="space-y-5">
|
||||
{#if errorMessage}
|
||||
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
|
||||
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showTwoFactor}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
||||
<Icon name="user" class="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="admin@aduanasoft.com"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
||||
<Icon name="lock" class="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- 2FA Input -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
|
||||
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
bind:value={totpCode}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full py-3 rounded border-blue-300 dark:border-blue-700 focus:ring-blue-500 focus:border-blue-500 text-center tracking-[0.5em] font-mono text-lg bg-white dark:bg-gray-800"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
|
||||
Consulte su dispositivo autenticador
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-2.5 px-4 rounded bg-gray-900 dark:bg-gray-700 text-white font-medium hover:bg-gray-800 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
|
||||
Autenticando...
|
||||
{:else}
|
||||
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
147
frontend-internal/src/routes/systems/+page.svelte
Normal file
147
frontend-internal/src/routes/systems/+page.svelte
Normal file
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
let systems = [];
|
||||
let isLoading = false;
|
||||
let showModal = false;
|
||||
let editingSystem = null;
|
||||
|
||||
let formData = {
|
||||
name: '',
|
||||
description: '',
|
||||
is_active: true
|
||||
};
|
||||
|
||||
async function loadSystems() {
|
||||
isLoading = true;
|
||||
try {
|
||||
systems = await api.get('/systems/');
|
||||
} catch (e) {
|
||||
toast.error('Error cargando sistemas');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingSystem = null;
|
||||
formData = { name: '', description: '', is_active: true };
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(system) {
|
||||
editingSystem = system;
|
||||
formData = { ...system };
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (editingSystem) {
|
||||
await api.put(`/systems/${editingSystem.id}`, formData);
|
||||
toast.success('Sistema actualizado');
|
||||
} else {
|
||||
await api.post('/systems/', formData);
|
||||
toast.success('Sistema creado');
|
||||
}
|
||||
showModal = false;
|
||||
loadSystems();
|
||||
} catch (e) {
|
||||
toast.error(e.message || 'Error guardando sistema');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadSystems);
|
||||
</script>
|
||||
|
||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Sistemas</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">Catálogo de sistemas informáticos gestionados.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
|
||||
>
|
||||
Nuevo Sistema
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Descripción</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">Acciones</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#if isLoading}
|
||||
<tr><td colspan="4" class="text-center py-4">Cargando...</td></tr>
|
||||
{:else if systems.length === 0}
|
||||
<tr><td colspan="4" class="text-center py-4">No hay sistemas registrados</td></tr>
|
||||
{:else}
|
||||
{#each systems as system}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{system.name}</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{system.description || '-'}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-green-100={system.is_active} class:text-green-800={system.is_active} class:bg-red-100={!system.is_active} class:text-red-800={!system.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
{system.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button on:click={() => openEditModal(system)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={showModal} title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
|
||||
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
|
||||
Guardar
|
||||
</button>
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
157
frontend-internal/src/routes/tenants/+page.svelte
Normal file
157
frontend-internal/src/routes/tenants/+page.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
let tenants = [];
|
||||
let isLoading = false;
|
||||
let showModal = false;
|
||||
let editingTenant = null;
|
||||
|
||||
let formData = {
|
||||
name: '',
|
||||
slug: '',
|
||||
domain: '',
|
||||
is_active: true
|
||||
};
|
||||
|
||||
async function loadTenants() {
|
||||
isLoading = true;
|
||||
try {
|
||||
tenants = await api.get('/tenants/');
|
||||
} catch (e) {
|
||||
toast.error('Error cargando clientes');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingTenant = null;
|
||||
formData = { name: '', slug: '', domain: '', is_active: true };
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(tenant) {
|
||||
editingTenant = tenant;
|
||||
formData = { ...tenant };
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (editingTenant) {
|
||||
await api.put(`/tenants/${editingTenant.id}`, formData);
|
||||
toast.success('Cliente actualizado');
|
||||
} else {
|
||||
await api.post('/tenants/', formData);
|
||||
toast.success('Cliente creado');
|
||||
}
|
||||
showModal = false;
|
||||
loadTenants();
|
||||
} catch (e) {
|
||||
toast.error(e.message || 'Error guardando cliente');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadTenants);
|
||||
</script>
|
||||
|
||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Clientes</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">Lista de todas las organizaciones/clientes registrados en el sistema.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
|
||||
>
|
||||
Nuevo Cliente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Dominio</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">Acciones</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#if isLoading}
|
||||
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
|
||||
{:else if tenants.length === 0}
|
||||
<tr><td colspan="5" class="text-center py-4">No hay clientes registrados</td></tr>
|
||||
{:else}
|
||||
{#each tenants as tenant}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{tenant.name}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.slug}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.domain || '-'}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-green-100={tenant.is_active} class:text-green-800={tenant.is_active} class:bg-red-100={!tenant.is_active} class:text-red-800={!tenant.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
{tenant.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button on:click={() => openEditModal(tenant)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={showModal} title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700">Slug (Identificador)</label>
|
||||
<input type="text" id="slug" bind:value={formData.slug} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<p class="text-xs text-gray-500 mt-1">Usado en URLs y subdominios.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="domain" class="block text-sm font-medium text-gray-700">Dominio Personalizado</label>
|
||||
<input type="text" id="domain" bind:value={formData.domain} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
|
||||
Guardar
|
||||
</button>
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
231
frontend-internal/src/routes/users/+page.svelte
Normal file
231
frontend-internal/src/routes/users/+page.svelte
Normal file
@@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
let users = [];
|
||||
let tenants = [];
|
||||
let isLoading = false;
|
||||
let showModal = false;
|
||||
let editingUser = null;
|
||||
|
||||
let formData = {
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
role: 'AGENT',
|
||||
tenant_id: '',
|
||||
is_active: true
|
||||
};
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'ADMIN', label: 'Administrador (Global)' },
|
||||
{ value: 'SUPPORT_MANAGER', label: 'Gerente de Soporte' },
|
||||
{ value: 'AGENT', label: 'Agente de Soporte' },
|
||||
{ value: 'AUDITOR', label: 'Auditor' },
|
||||
{ value: 'CLIENT_ADMIN', label: 'Admin Cliente' },
|
||||
{ value: 'CLIENT_USER', label: 'Usuario Cliente' }
|
||||
];
|
||||
|
||||
async function loadData() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const [usersData, tenantsData] = await Promise.all([
|
||||
api.get('/users/'),
|
||||
api.get('/tenants/')
|
||||
]);
|
||||
users = usersData;
|
||||
tenants = tenantsData;
|
||||
} catch (e) {
|
||||
toast.error('Error cargando datos');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingUser = null;
|
||||
formData = {
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
role: 'AGENT',
|
||||
tenant_id: '',
|
||||
is_active: true
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(user) {
|
||||
editingUser = user;
|
||||
formData = {
|
||||
email: user.email,
|
||||
password: '', // Don't show password
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
tenant_id: user.tenant_id || '',
|
||||
is_active: user.is_active
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
const payload = { ...formData };
|
||||
if (!payload.password) delete payload.password; // Don't send empty password on edit
|
||||
if (!payload.tenant_id) payload.tenant_id = null; // Send null if empty string
|
||||
|
||||
if (editingUser) {
|
||||
await api.put(`/users/${editingUser.id}`, payload);
|
||||
toast.success('Usuario actualizado');
|
||||
} else {
|
||||
if (!payload.password) {
|
||||
toast.error('La contraseña es requerida para nuevos usuarios');
|
||||
return;
|
||||
}
|
||||
await api.post('/users/', payload);
|
||||
toast.success('Usuario creado');
|
||||
}
|
||||
showModal = false;
|
||||
loadData();
|
||||
} catch (e) {
|
||||
toast.error(e.message || 'Error guardando usuario');
|
||||
}
|
||||
}
|
||||
|
||||
function getTenantName(id) {
|
||||
if (!id) return '-';
|
||||
const t = tenants.find(t => t.id === id);
|
||||
return t ? t.name : id;
|
||||
}
|
||||
|
||||
onMount(loadData);
|
||||
</script>
|
||||
|
||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Usuarios</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">Gestión de usuarios internos y de clientes.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
|
||||
>
|
||||
Nuevo Usuario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Usuario</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Rol</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente (Tenant)</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">Acciones</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#if isLoading}
|
||||
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
|
||||
{:else if users.length === 0}
|
||||
<tr><td colspan="5" class="text-center py-4">No hay usuarios registrados</td></tr>
|
||||
{:else}
|
||||
{#each users as user}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm sm:pl-6">
|
||||
<div class="font-medium text-gray-900">{user.first_name} {user.last_name}</div>
|
||||
<div class="text-gray-500">{user.email}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{getTenantName(user.tenant_id)}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-green-100={user.is_active} class:text-green-800={user.is_active} class:bg-red-100={!user.is_active} class:text-red-800={!user.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
{user.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button on:click={() => openEditModal(user)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={showModal} title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700">Nombre</label>
|
||||
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700">Apellido</label>
|
||||
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}</label>
|
||||
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium text-gray-700">Rol</label>
|
||||
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
{#each ROLES as role}
|
||||
<option value={role.value}>{role.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - solo para usuarios externos)</label>
|
||||
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<option value="">-- Ninguno (Usuario Interno) --</option>
|
||||
{#each tenants as tenant}
|
||||
<option value={tenant.id}>{tenant.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
|
||||
Guardar
|
||||
</button>
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
25
frontend-internal/svelte.config.js
Normal file
25
frontend-internal/svelte.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/kit/vite';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
|
||||
alias: {
|
||||
'$components': 'src/lib/components',
|
||||
'$stores': 'src/lib/stores',
|
||||
'$utils': 'src/lib/utils',
|
||||
'$types': 'src/lib/types'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
23
frontend-internal/tailwind.config.js
Normal file
23
frontend-internal/tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
24
frontend-internal/vite.config.js
Normal file
24
frontend-internal/vite.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api/v1': {
|
||||
target: 'http://servicemanager-backend:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext'
|
||||
}
|
||||
});
|
||||
104
scripts/README.md
Normal file
104
scripts/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# ServiceManagerWeb - Scripts de Utilidad
|
||||
|
||||
Este directorio contiene scripts para desarrollo y despliegue del sistema.
|
||||
|
||||
## Scripts de Desarrollo
|
||||
|
||||
### setup-dev.sh / setup-dev.ps1
|
||||
```bash
|
||||
# Linux/Mac
|
||||
./scripts/setup-dev.sh
|
||||
|
||||
# Windows
|
||||
./scripts/setup-dev.ps1
|
||||
```
|
||||
|
||||
Configura el entorno de desarrollo:
|
||||
- Copia .env.example a .env
|
||||
- Genera claves secretas seguras
|
||||
- Levanta servicios con Docker Compose
|
||||
- Ejecuta migraciones iniciales
|
||||
- Carga datos de prueba
|
||||
|
||||
### db-migrate.sh / db-migrate.ps1
|
||||
```bash
|
||||
# Ejecutar migraciones pendientes
|
||||
./scripts/db-migrate.sh
|
||||
|
||||
# Crear nueva migración
|
||||
./scripts/db-migrate.sh "add_user_preferences"
|
||||
```
|
||||
|
||||
### test-all.sh / test-all.ps1
|
||||
```bash
|
||||
# Ejecutar todos los tests
|
||||
./scripts/test-all.sh
|
||||
|
||||
# Solo backend
|
||||
./scripts/test-all.sh backend
|
||||
|
||||
# Con coverage
|
||||
./scripts/test-all.sh --coverage
|
||||
```
|
||||
|
||||
### lint-fix.sh / lint-fix.ps1
|
||||
```bash
|
||||
# Linting y formateo automático
|
||||
./scripts/lint-fix.sh
|
||||
```
|
||||
|
||||
## Scripts de Producción
|
||||
|
||||
### deploy.sh
|
||||
```bash
|
||||
# Despliegue en producción
|
||||
./scripts/deploy.sh production
|
||||
|
||||
# Con backup automático
|
||||
./scripts/deploy.sh production --backup
|
||||
```
|
||||
|
||||
### backup.sh
|
||||
```bash
|
||||
# Backup completo
|
||||
./scripts/backup.sh
|
||||
|
||||
# Solo base de datos
|
||||
./scripts/backup.sh --db-only
|
||||
```
|
||||
|
||||
### health-check.sh
|
||||
```bash
|
||||
# Verificar estado de servicios
|
||||
./scripts/health-check.sh
|
||||
```
|
||||
|
||||
## Scripts de Mantenimiento
|
||||
|
||||
### cleanup.sh
|
||||
```bash
|
||||
# Limpiar logs antiguos y archivos temporales
|
||||
./scripts/cleanup.sh
|
||||
|
||||
# Limpiar containers e imágenes sin usar
|
||||
./scripts/cleanup.sh --docker
|
||||
```
|
||||
|
||||
### seed-data.sh
|
||||
```bash
|
||||
# Cargar datos de prueba
|
||||
./scripts/seed-data.sh
|
||||
|
||||
# Cargar datos específicos
|
||||
./scripts/seed-data.sh --fixture=users
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
Todos los scripts incluyen help integrado:
|
||||
|
||||
```bash
|
||||
./scripts/script-name.sh --help
|
||||
```
|
||||
|
||||
Los scripts están disponibles tanto para Unix (sh) como Windows (ps1).
|
||||
165
scripts/setup-dev.sh
Normal file
165
scripts/setup-dev.sh
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ServiceManagerWeb - Setup de Desarrollo
|
||||
# Configura el entorno de desarrollo completo
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Configurando ServiceManagerWeb - Entorno de Desarrollo"
|
||||
|
||||
# Colores para output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Función para logs
|
||||
log() {
|
||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')] $1${NC}"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Verificar dependencias
|
||||
check_dependencies() {
|
||||
log "Verificando dependencias..."
|
||||
|
||||
command -v docker >/dev/null 2>&1 || error "Docker no está instalado"
|
||||
command -v docker-compose >/dev/null 2>&1 || error "Docker Compose no está instalado"
|
||||
|
||||
success "Dependencias verificadas"
|
||||
}
|
||||
|
||||
# Configurar archivo .env
|
||||
setup_env() {
|
||||
log "Configurando variables de entorno..."
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
|
||||
# Generar claves secretas
|
||||
SECRET_KEY=$(openssl rand -base64 32)
|
||||
JWT_SECRET_KEY=$(openssl rand -base64 32)
|
||||
|
||||
# Reemplazar en .env
|
||||
sed -i "s|your-super-secret-key-change-in-production-min-32-chars|$SECRET_KEY|g" .env
|
||||
sed -i "s|jwt-secret-key-change-in-production-min-32-chars|$JWT_SECRET_KEY|g" .env
|
||||
|
||||
success "Archivo .env creado con claves seguras"
|
||||
else
|
||||
warning "Archivo .env ya existe, no se sobrescribirá"
|
||||
fi
|
||||
}
|
||||
|
||||
# Crear directorios necesarios
|
||||
create_directories() {
|
||||
log "Creando directorios necesarios..."
|
||||
|
||||
mkdir -p logs uploads
|
||||
chmod 755 logs uploads
|
||||
|
||||
success "Directorios creados"
|
||||
}
|
||||
|
||||
# Levantar servicios
|
||||
start_services() {
|
||||
log "Levantando servicios con Docker Compose..."
|
||||
|
||||
docker-compose --profile dev up -d
|
||||
|
||||
success "Servicios iniciados"
|
||||
}
|
||||
|
||||
# Esperar a que la base de datos esté lista
|
||||
wait_for_db() {
|
||||
log "Esperando a que PostgreSQL esté listo..."
|
||||
|
||||
timeout=60
|
||||
while ! docker-compose exec -T postgres pg_isready -U servicemanager >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
timeout=$((timeout - 2))
|
||||
if [ $timeout -le 0 ]; then
|
||||
error "Timeout esperando PostgreSQL"
|
||||
fi
|
||||
done
|
||||
|
||||
success "PostgreSQL está listo"
|
||||
}
|
||||
|
||||
# Ejecutar migraciones (cuando estén implementadas)
|
||||
run_migrations() {
|
||||
log "Las migraciones se ejecutarán cuando el backend esté implementado..."
|
||||
warning "Saltando migraciones por ahora"
|
||||
}
|
||||
|
||||
# Mostrar información útil
|
||||
show_info() {
|
||||
echo ""
|
||||
echo "🎉 ¡Configuración completa!"
|
||||
echo ""
|
||||
echo "📊 URLs disponibles:"
|
||||
echo " Frontend Cliente: http://localhost:3000"
|
||||
echo " Frontend Interno: http://localhost:3001"
|
||||
echo " API Backend: http://localhost:8000"
|
||||
echo " API Docs: http://localhost:8000/docs"
|
||||
echo ""
|
||||
echo "🛠️ Herramientas de desarrollo:"
|
||||
echo " Adminer (DB): http://localhost:8080"
|
||||
echo " MailHog (Email): http://localhost:8025"
|
||||
echo " Redis Commander: http://localhost:8081"
|
||||
echo ""
|
||||
echo "📝 Comandos útiles:"
|
||||
echo " Ver logs: docker-compose logs -f"
|
||||
echo " Parar servicios: docker-compose down"
|
||||
echo " Reiniciar todo: docker-compose restart"
|
||||
echo " Shell backend: docker-compose exec backend bash"
|
||||
echo ""
|
||||
echo "👤 Usuario por defecto:"
|
||||
echo " Email: admin@aduanasoft.com"
|
||||
echo " Password: admin123"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Función principal
|
||||
main() {
|
||||
check_dependencies
|
||||
setup_env
|
||||
create_directories
|
||||
start_services
|
||||
wait_for_db
|
||||
run_migrations
|
||||
show_info
|
||||
}
|
||||
|
||||
# Manejar argumentos
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
echo "Uso: $0 [opciones]"
|
||||
echo ""
|
||||
echo "Opciones:"
|
||||
echo " -h, --help Mostrar esta ayuda"
|
||||
echo " --no-start Solo configurar, no iniciar servicios"
|
||||
echo ""
|
||||
exit 0
|
||||
;;
|
||||
--no-start)
|
||||
setup_env
|
||||
create_directories
|
||||
success "Configuración completada sin iniciar servicios"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
main "$@"
|
||||
279
workers/README.md
Normal file
279
workers/README.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# ServiceManagerWeb Workers
|
||||
|
||||
Workers asíncronos con Celery para el sistema de Mesa de Ayuda B2B.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
workers/
|
||||
├── app/
|
||||
│ ├── celery.py # Configuración principal de Celery
|
||||
│ ├── core/ # Configuración compartida
|
||||
│ │ ├── config.py # Settings para workers
|
||||
│ │ └── logging.py # Logging estructurado
|
||||
│ └── tasks/ # Tareas por dominio
|
||||
│ ├── email_tasks.py # Envío de emails
|
||||
│ ├── sla_tasks.py # Monitoreo de SLAs
|
||||
│ ├── maintenance_tasks.py # Mantenimiento del sistema
|
||||
│ └── notification_tasks.py # Notificaciones y digests
|
||||
├── requirements.txt # Dependencias Python
|
||||
└── README.md # Esta documentación
|
||||
```
|
||||
|
||||
## Tareas Implementadas
|
||||
|
||||
### Email Tasks (`email_tasks.py`)
|
||||
- [x] `send_email_task`: Envío básico de emails SMTP
|
||||
- [x] `send_templated_email_task`: Emails con plantillas Jinja2
|
||||
- [x] `send_bulk_email_task`: Envío masivo con progreso
|
||||
|
||||
### SLA Tasks (`sla_tasks.py`)
|
||||
- [x] `check_sla_violations`: Monitoreo de violaciones SLA
|
||||
- [x] `calculate_sla_metrics`: Cálculo de métricas SLA
|
||||
- [x] `send_sla_warnings`: Alertas de SLAs próximos a vencer
|
||||
|
||||
### Maintenance Tasks (`maintenance_tasks.py`)
|
||||
- [x] `health_check`: Health check de workers
|
||||
- [x] `cleanup_old_logs`: Limpieza de logs antiguos
|
||||
- [x] `generate_weekly_reports`: Reportes semanales
|
||||
- [x] `cleanup_temp_files`: Limpieza de archivos temporales
|
||||
- [x] `database_maintenance`: Mantenimiento de BD
|
||||
|
||||
### Notification Tasks (`notification_tasks.py`)
|
||||
- [x] `send_daily_digest`: Digest diario para agentes
|
||||
- [x] `send_ticket_notifications`: Notificaciones de tickets
|
||||
- [x] `send_system_alert`: Alertas del sistema
|
||||
|
||||
## Programación Automática (Celery Beat)
|
||||
|
||||
### Tareas Periódicas Configuradas
|
||||
|
||||
```python
|
||||
# Cada 5 minutos
|
||||
"check-sla-violations": check_sla_violations
|
||||
|
||||
# Diario a las 8:00 AM
|
||||
"send-daily-digest": send_daily_digest
|
||||
|
||||
# Semanal los domingos a las 2:00 AM
|
||||
"cleanup-old-logs": cleanup_old_logs
|
||||
|
||||
# Semanal los lunes a las 9:00 AM
|
||||
"generate-weekly-reports": generate_weekly_reports
|
||||
|
||||
# Cada minuto (health check)
|
||||
"worker-health-check": health_check
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Desarrollo Local
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Variables de entorno (usar las del proyecto principal)
|
||||
cp ../.env.example .env
|
||||
|
||||
# Ejecutar worker
|
||||
celery -A app.celery worker --loglevel=info
|
||||
|
||||
# Ejecutar beat scheduler (en otra terminal)
|
||||
celery -A app.celery beat --loglevel=info
|
||||
|
||||
# Monitoreo con Flower (opcional)
|
||||
celery -A app.celery flower
|
||||
```
|
||||
|
||||
### Con Docker
|
||||
|
||||
```bash
|
||||
# Worker y Beat se ejecutan automáticamente con docker-compose
|
||||
docker-compose up worker beat
|
||||
|
||||
# Ver logs
|
||||
docker-compose logs -f worker
|
||||
docker-compose logs -f beat
|
||||
```
|
||||
|
||||
## Configuración
|
||||
|
||||
### Variables de Entorno Importantes
|
||||
|
||||
```bash
|
||||
# Celery
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
|
||||
# Email
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
DEFAULT_FROM_EMAIL=noreply@servicemanager.local
|
||||
|
||||
# SLA
|
||||
SLA_CHECK_ENABLED=true
|
||||
SLA_WARNING_THRESHOLD=0.8
|
||||
|
||||
# Mantenimiento
|
||||
LOG_RETENTION_DAYS=30
|
||||
DIGEST_ENABLED=true
|
||||
```
|
||||
|
||||
### Colas de Trabajo
|
||||
|
||||
- **default**: Tareas generales
|
||||
- **email**: Envío de emails
|
||||
- **sla**: Monitoreo SLA
|
||||
- **maintenance**: Mantenimiento
|
||||
- **notifications**: Notificaciones
|
||||
|
||||
## Monitoreo
|
||||
|
||||
### Logs Estructurados
|
||||
|
||||
Todos los workers utilizan structured logging con:
|
||||
- Task ID único
|
||||
- Correlation ID para tracking
|
||||
- Contexto de tenant
|
||||
- Métricas de performance
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Health check manual
|
||||
celery -A app.celery inspect ping
|
||||
|
||||
# Estadísticas de workers
|
||||
celery -A app.celery inspect stats
|
||||
|
||||
# Tareas activas
|
||||
celery -A app.celery inspect active
|
||||
```
|
||||
|
||||
### Métricas
|
||||
|
||||
- Task execution times
|
||||
- Success/failure rates
|
||||
- Queue lengths
|
||||
- Worker load
|
||||
|
||||
## Desarrollo
|
||||
|
||||
### Agregar Nueva Tarea
|
||||
|
||||
1. Crear función en módulo apropiado:
|
||||
```python
|
||||
@celery_app.task(bind=True, time_limit=300)
|
||||
def new_task(self, param1: str, param2: int):
|
||||
logger = get_logger(__name__)
|
||||
# Implementation
|
||||
return result
|
||||
```
|
||||
|
||||
2. Registrar en `celery.py` si es periódica:
|
||||
```python
|
||||
beat_schedule = {
|
||||
"new-periodic-task": {
|
||||
"task": "app.tasks.module.new_task",
|
||||
"schedule": crontab(minute=0, hour=9),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Agregar tests en `tests/`
|
||||
|
||||
### Retry y Error Handling
|
||||
|
||||
```python
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
autoretry_for=(ConnectionError, TimeoutError),
|
||||
retry_kwargs={'max_retries': 3, 'countdown': 60}
|
||||
)
|
||||
def reliable_task(self):
|
||||
# Task que se reintenta automáticamente
|
||||
pass
|
||||
```
|
||||
|
||||
### Templates de Email
|
||||
|
||||
Los templates están definidos en código por ahora. En el futuro se moverán a base de datos para ser editables por tenants.
|
||||
|
||||
Templates disponibles:
|
||||
- `ticket_created`
|
||||
- `ticket_assigned`
|
||||
- `ticket_resolved`
|
||||
- `sla_warning`
|
||||
- `sla_violation`
|
||||
- `daily_digest`
|
||||
- `system_alert`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Ejecutar tests
|
||||
pytest
|
||||
|
||||
# Tests específicos de workers
|
||||
pytest tests/test_tasks/
|
||||
|
||||
# Ejecutar tarea manualmente para testing
|
||||
celery -A app.celery call app.tasks.email_tasks.send_email_task --args='["test@example.com", "Test Subject", "<h1>Test</h1>"]'
|
||||
```
|
||||
|
||||
## Producción
|
||||
|
||||
### Configuración Recomendada
|
||||
|
||||
```bash
|
||||
# Múltiples workers por queue
|
||||
celery -A app.celery worker --loglevel=info --concurrency=4 --queues=email
|
||||
celery -A app.celery worker --loglevel=info --concurrency=2 --queues=sla,maintenance
|
||||
celery -A app.celery worker --loglevel=info --concurrency=1 --queues=default
|
||||
|
||||
# Beat scheduler (solo una instancia)
|
||||
celery -A app.celery beat --loglevel=info
|
||||
|
||||
# Con systemd o supervisor para auto-restart
|
||||
```
|
||||
|
||||
### Optimizaciones
|
||||
|
||||
- Pool de conexiones Redis
|
||||
- Compresión de mensajes grandes
|
||||
- Rate limiting por tarea
|
||||
- Monitoring con Prometheus/Grafana
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problemas Comunes
|
||||
|
||||
1. **Tasks stuck in queue**:
|
||||
- Verificar workers activos
|
||||
- Revisar configuración de routing
|
||||
|
||||
2. **Memory leaks**:
|
||||
- Configurar `worker_max_tasks_per_child`
|
||||
- Monitorear uso de memoria
|
||||
|
||||
3. **Email delivery failures**:
|
||||
- Verificar configuración SMTP
|
||||
- Revisar logs de tareas email
|
||||
|
||||
4. **SLA false positives**:
|
||||
- Verificar timezones
|
||||
- Validar lógica de cálculo
|
||||
|
||||
### Debug
|
||||
|
||||
```bash
|
||||
# Ejecutar worker en modo debug
|
||||
celery -A app.celery worker --loglevel=debug
|
||||
|
||||
# Inspeccionar tareas fallidas
|
||||
celery -A app.celery inspect failed
|
||||
|
||||
# Purgar queue
|
||||
celery -A app.celery purge -Q queue_name
|
||||
```
|
||||
129
workers/app/celery.py
Normal file
129
workers/app/celery.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Celery Application - ServiceManagerWeb Workers
|
||||
|
||||
Configuración principal de Celery para tareas asíncronas
|
||||
"""
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
import os
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
# Setup logging
|
||||
setup_logging()
|
||||
|
||||
# Get settings
|
||||
settings = get_settings()
|
||||
|
||||
# Create Celery application
|
||||
celery_app = Celery(
|
||||
"servicemanager-workers",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND,
|
||||
include=[
|
||||
"app.tasks.email_tasks",
|
||||
"app.tasks.sla_tasks",
|
||||
"app.tasks.maintenance_tasks",
|
||||
"app.tasks.notification_tasks"
|
||||
]
|
||||
)
|
||||
|
||||
# Configure Celery
|
||||
celery_app.conf.update(
|
||||
# Task settings
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
|
||||
# Result backend settings
|
||||
result_expires=3600, # 1 hour
|
||||
result_persistent=True,
|
||||
|
||||
# Worker settings
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_max_tasks_per_child=1000,
|
||||
worker_disable_rate_limits=False,
|
||||
|
||||
# Task routing
|
||||
task_routes={
|
||||
"app.tasks.email_tasks.*": {"queue": "email"},
|
||||
"app.tasks.sla_tasks.*": {"queue": "sla"},
|
||||
"app.tasks.maintenance_tasks.*": {"queue": "maintenance"},
|
||||
"app.tasks.notification_tasks.*": {"queue": "notifications"},
|
||||
},
|
||||
|
||||
# Queue configuration
|
||||
task_default_queue="default",
|
||||
task_default_exchange="default",
|
||||
task_default_routing_key="default",
|
||||
|
||||
# Beat schedule for periodic tasks
|
||||
beat_schedule={
|
||||
# Check SLA violations every 5 minutes
|
||||
"check-sla-violations": {
|
||||
"task": "app.tasks.sla_tasks.check_sla_violations",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
},
|
||||
|
||||
# Send daily digest at 8:00 AM
|
||||
"send-daily-digest": {
|
||||
"task": "app.tasks.notification_tasks.send_daily_digest",
|
||||
"schedule": crontab(hour=8, minute=0),
|
||||
},
|
||||
|
||||
# Clean old logs weekly on Sunday at 2:00 AM
|
||||
"cleanup-old-logs": {
|
||||
"task": "app.tasks.maintenance_tasks.cleanup_old_logs",
|
||||
"schedule": crontab(hour=2, minute=0, day_of_week=0),
|
||||
},
|
||||
|
||||
# Generate weekly reports on Monday at 9:00 AM
|
||||
"generate-weekly-reports": {
|
||||
"task": "app.tasks.maintenance_tasks.generate_weekly_reports",
|
||||
"schedule": crontab(hour=9, minute=0, day_of_week=1),
|
||||
},
|
||||
|
||||
# Health check every minute
|
||||
"worker-health-check": {
|
||||
"task": "app.tasks.maintenance_tasks.health_check",
|
||||
"schedule": crontab(minute="*/1"),
|
||||
},
|
||||
},
|
||||
|
||||
# Error handling
|
||||
task_reject_on_worker_lost=True,
|
||||
task_acks_late=True,
|
||||
|
||||
# Monitoring
|
||||
worker_send_task_events=True,
|
||||
task_send_sent_event=True,
|
||||
|
||||
# Security
|
||||
worker_hijack_root_logger=False,
|
||||
worker_log_format="[%(asctime)s: %(levelname)s/%(processName)s] %(message)s",
|
||||
worker_task_log_format="[%(asctime)s: %(levelname)s/%(processName)s][%(task_name)s(%(task_id)s)] %(message)s",
|
||||
)
|
||||
|
||||
# Optional: Configure SSL if needed
|
||||
if settings.ENVIRONMENT == "production":
|
||||
# Enable SSL for production
|
||||
celery_app.conf.update(
|
||||
broker_use_ssl=True,
|
||||
redis_backend_use_ssl=True,
|
||||
)
|
||||
|
||||
|
||||
# Import all tasks to register them
|
||||
from app.tasks import (
|
||||
email_tasks,
|
||||
sla_tasks,
|
||||
maintenance_tasks,
|
||||
notification_tasks
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
celery_app.start()
|
||||
117
workers/app/core/config.py
Normal file
117
workers/app/core/config.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Core Configuration for Workers - ServiceManagerWeb
|
||||
|
||||
Configuración compartida entre workers usando Pydantic Settings
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
|
||||
|
||||
class WorkerSettings(BaseSettings):
|
||||
"""Configuración para workers Celery."""
|
||||
|
||||
# ===================================
|
||||
# GENERAL
|
||||
# ===================================
|
||||
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT")
|
||||
DEBUG: bool = Field(default=False, env="DEBUG")
|
||||
|
||||
# ===================================
|
||||
# DATABASE
|
||||
# ===================================
|
||||
DATABASE_URL: str = Field(..., env="DATABASE_URL")
|
||||
|
||||
# ===================================
|
||||
# CELERY & REDIS
|
||||
# ===================================
|
||||
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
|
||||
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND")
|
||||
REDIS_URL: str = Field(..., env="REDIS_URL")
|
||||
|
||||
# ===================================
|
||||
# EMAIL SETTINGS
|
||||
# ===================================
|
||||
SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST")
|
||||
SMTP_PORT: int = Field(default=587, env="SMTP_PORT")
|
||||
SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER")
|
||||
SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD")
|
||||
SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS")
|
||||
SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL")
|
||||
|
||||
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL")
|
||||
DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME")
|
||||
|
||||
# Email retry settings
|
||||
EMAIL_MAX_RETRIES: int = Field(default=3, env="EMAIL_MAX_RETRIES")
|
||||
EMAIL_RETRY_DELAY: int = Field(default=60, env="EMAIL_RETRY_DELAY") # seconds
|
||||
|
||||
# ===================================
|
||||
# FILE PROCESSING
|
||||
# ===================================
|
||||
UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH")
|
||||
MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB")
|
||||
|
||||
# ===================================
|
||||
# SLA SETTINGS
|
||||
# ===================================
|
||||
SLA_CHECK_ENABLED: bool = Field(default=True, env="SLA_CHECK_ENABLED")
|
||||
SLA_WARNING_THRESHOLD: float = Field(default=0.8, env="SLA_WARNING_THRESHOLD") # 80% of SLA time
|
||||
|
||||
# ===================================
|
||||
# LOGGING
|
||||
# ===================================
|
||||
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT")
|
||||
LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE")
|
||||
|
||||
# ===================================
|
||||
# MONITORING
|
||||
# ===================================
|
||||
SENTRY_DSN: Optional[str] = Field(default=None, env="SENTRY_DSN")
|
||||
PROMETHEUS_PORT: int = Field(default=8888, env="PROMETHEUS_PORT")
|
||||
|
||||
# ===================================
|
||||
# TASK SETTINGS
|
||||
# ===================================
|
||||
TASK_TIME_LIMIT: int = Field(default=300, env="TASK_TIME_LIMIT") # 5 minutes
|
||||
TASK_SOFT_TIME_LIMIT: int = Field(default=240, env="TASK_SOFT_TIME_LIMIT") # 4 minutes
|
||||
|
||||
# ===================================
|
||||
# MAINTENANCE SETTINGS
|
||||
# ===================================
|
||||
LOG_RETENTION_DAYS: int = Field(default=30, env="LOG_RETENTION_DAYS")
|
||||
ATTACHMENT_RETENTION_DAYS: int = Field(default=90, env="ATTACHMENT_RETENTION_DAYS")
|
||||
|
||||
# ===================================
|
||||
# NOTIFICATION SETTINGS
|
||||
# ===================================
|
||||
NOTIFICATION_BATCH_SIZE: int = Field(default=100, env="NOTIFICATION_BATCH_SIZE")
|
||||
DIGEST_ENABLED: bool = Field(default=True, env="DIGEST_ENABLED")
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"case_sensitive": True
|
||||
}
|
||||
|
||||
def is_production(self) -> bool:
|
||||
"""Check if environment is production."""
|
||||
return self.ENVIRONMENT.lower() == "production"
|
||||
|
||||
def is_development(self) -> bool:
|
||||
"""Check if environment is development."""
|
||||
return self.ENVIRONMENT.lower() == "development"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> WorkerSettings:
|
||||
"""
|
||||
Get cached settings instance.
|
||||
|
||||
Using lru_cache to create a singleton pattern for settings.
|
||||
"""
|
||||
return WorkerSettings()
|
||||
122
workers/app/core/logging.py
Normal file
122
workers/app/core/logging.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Logging Configuration for Workers - ServiceManagerWeb
|
||||
|
||||
Configuración de logging estructurado para workers Celery
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
from typing import Any, Dict
|
||||
import structlog
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Configure structured logging for workers."""
|
||||
|
||||
processors = [
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
# Add worker-specific context
|
||||
structlog.processors.CallsiteParameterAdder(
|
||||
parameters=[
|
||||
structlog.processors.CallsiteParameter.FUNC_NAME,
|
||||
structlog.processors.CallsiteParameter.PATHNAME,
|
||||
structlog.processors.CallsiteParameter.LINENO,
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
if settings.LOG_FORMAT == "json":
|
||||
processors.append(structlog.processors.JSONRenderer())
|
||||
else:
|
||||
processors.append(structlog.dev.ConsoleRenderer(colors=True))
|
||||
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
context_class=dict,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
# Configure standard library logging for Celery
|
||||
logging_config = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
"processor": structlog.processors.JSONRenderer(),
|
||||
},
|
||||
"console": {
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
"processor": structlog.dev.ConsoleRenderer(colors=True),
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": settings.LOG_LEVEL,
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": sys.stdout,
|
||||
"formatter": "json" if settings.LOG_FORMAT == "json" else "console",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"": { # root logger
|
||||
"handlers": ["console"],
|
||||
"level": settings.LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"celery": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.worker": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.task": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"app": {
|
||||
"handlers": ["console"],
|
||||
"level": settings.LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Add file handler if specified
|
||||
if settings.LOG_FILE:
|
||||
logging_config["handlers"]["file"] = {
|
||||
"level": settings.LOG_LEVEL,
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": settings.LOG_FILE,
|
||||
"maxBytes": 10 * 1024 * 1024, # 10MB
|
||||
"backupCount": 5,
|
||||
"formatter": "json",
|
||||
}
|
||||
|
||||
for logger_config in logging_config["loggers"].values():
|
||||
logger_config["handlers"].append("file")
|
||||
|
||||
logging.config.dictConfig(logging_config)
|
||||
|
||||
|
||||
def get_logger(name: str = None) -> structlog.BoundLogger:
|
||||
"""Get a configured structlog logger."""
|
||||
return structlog.get_logger(name)
|
||||
404
workers/app/tasks/email_tasks.py
Normal file
404
workers/app/tasks/email_tasks.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Email Tasks - ServiceManagerWeb Workers
|
||||
|
||||
Tareas asíncronas para envío de emails y notificaciones
|
||||
"""
|
||||
|
||||
from celery import current_task
|
||||
from celery.exceptions import Retry
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
import smtplib
|
||||
import ssl
|
||||
from typing import Dict, List, Optional, Any
|
||||
from jinja2 import Template, Environment, BaseLoader
|
||||
import structlog
|
||||
|
||||
from app.celery import celery_app
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MemoryLoader(BaseLoader):
|
||||
"""Jinja2 loader for templates from memory/database."""
|
||||
|
||||
def __init__(self, templates: Dict[str, str]):
|
||||
self.templates = templates
|
||||
|
||||
def get_source(self, environment, template):
|
||||
if template in self.templates:
|
||||
source = self.templates[template]
|
||||
return source, None, lambda: True
|
||||
raise TemplateNotFoundError(template)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
autoretry_for=(Exception,),
|
||||
retry_kwargs={'max_retries': 3, 'countdown': 60},
|
||||
time_limit=120,
|
||||
soft_time_limit=90
|
||||
)
|
||||
def send_email_task(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: Optional[str] = None,
|
||||
from_email: Optional[str] = None,
|
||||
from_name: Optional[str] = None,
|
||||
attachments: Optional[List[Dict[str, Any]]] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send email using SMTP.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
subject: Email subject
|
||||
html_content: HTML content
|
||||
text_content: Plain text content (optional)
|
||||
from_email: Sender email (optional, uses default)
|
||||
from_name: Sender name (optional)
|
||||
attachments: List of attachment dicts
|
||||
tenant_id: Tenant ID for logging
|
||||
correlation_id: Correlation ID for tracking
|
||||
|
||||
Returns:
|
||||
Dict with send result
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
tenant_id=tenant_id,
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
task_logger.info(
|
||||
"Starting email send task",
|
||||
to_email=to_email,
|
||||
subject=subject
|
||||
)
|
||||
|
||||
try:
|
||||
# Prepare email
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f"{from_name or settings.DEFAULT_FROM_NAME} <{from_email or settings.DEFAULT_FROM_EMAIL}>"
|
||||
msg['To'] = to_email
|
||||
|
||||
# Add text content
|
||||
if text_content:
|
||||
text_part = MIMEText(text_content, 'plain', 'utf-8')
|
||||
msg.attach(text_part)
|
||||
|
||||
# Add HTML content
|
||||
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||
msg.attach(html_part)
|
||||
|
||||
# Add attachments
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
part = MIMEBase('application', 'octet-stream')
|
||||
part.set_payload(attachment['content'])
|
||||
encoders.encode_base64(part)
|
||||
part.add_header(
|
||||
'Content-Disposition',
|
||||
f'attachment; filename= {attachment["filename"]}'
|
||||
)
|
||||
msg.attach(part)
|
||||
|
||||
# Send email
|
||||
context = ssl.create_default_context()
|
||||
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
if settings.SMTP_USE_TLS:
|
||||
server.starttls(context=context)
|
||||
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
|
||||
server.send_message(msg)
|
||||
|
||||
task_logger.info(
|
||||
"Email sent successfully",
|
||||
to_email=to_email,
|
||||
subject=subject
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"to_email": to_email,
|
||||
"subject": subject,
|
||||
"sent_at": current_task.request.eta or "now"
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Failed to send email",
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Check if we should retry
|
||||
if self.request.retries < self.max_retries:
|
||||
task_logger.info(
|
||||
"Retrying email send",
|
||||
retry_count=self.request.retries + 1,
|
||||
max_retries=self.max_retries
|
||||
)
|
||||
raise self.retry(countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"to_email": to_email,
|
||||
"subject": subject,
|
||||
"error": str(exc)
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=300,
|
||||
soft_time_limit=240
|
||||
)
|
||||
def send_templated_email_task(
|
||||
self,
|
||||
to_email: str,
|
||||
template_name: str,
|
||||
context: Dict[str, Any],
|
||||
tenant_id: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send email using a template.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email
|
||||
template_name: Template identifier
|
||||
context: Template context variables
|
||||
tenant_id: Tenant ID
|
||||
correlation_id: Correlation ID
|
||||
|
||||
Returns:
|
||||
Dict with send result
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
tenant_id=tenant_id,
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
task_logger.info(
|
||||
"Starting templated email task",
|
||||
to_email=to_email,
|
||||
template_name=template_name
|
||||
)
|
||||
|
||||
try:
|
||||
# TODO: Fetch template from database
|
||||
# For now, use mock templates
|
||||
templates = {
|
||||
"ticket_created": {
|
||||
"subject": "Nuevo ticket #{{ ticket_number }}: {{ subject }}",
|
||||
"html": """
|
||||
<h2>Nuevo ticket creado</h2>
|
||||
<p>Hola {{ user_name }},</p>
|
||||
<p>Se ha creado un nuevo ticket:</p>
|
||||
<ul>
|
||||
<li><strong>Número:</strong> #{{ ticket_number }}</li>
|
||||
<li><strong>Asunto:</strong> {{ subject }}</li>
|
||||
<li><strong>Prioridad:</strong> {{ priority }}</li>
|
||||
</ul>
|
||||
<p><a href="{{ ticket_url }}">Ver ticket</a></p>
|
||||
<p>Saludos,<br>Equipo de Soporte</p>
|
||||
""",
|
||||
"text": """
|
||||
Nuevo ticket creado
|
||||
|
||||
Hola {{ user_name }},
|
||||
|
||||
Se ha creado un nuevo ticket:
|
||||
|
||||
Número: #{{ ticket_number }}
|
||||
Asunto: {{ subject }}
|
||||
Prioridad: {{ priority }}
|
||||
|
||||
Ver ticket: {{ ticket_url }}
|
||||
|
||||
Saludos,
|
||||
Equipo de Soporte
|
||||
"""
|
||||
},
|
||||
"ticket_assigned": {
|
||||
"subject": "Ticket #{{ ticket_number }} asignado a ti",
|
||||
"html": """
|
||||
<h2>Ticket asignado</h2>
|
||||
<p>Hola {{ agent_name }},</p>
|
||||
<p>Se te ha asignado el ticket:</p>
|
||||
<ul>
|
||||
<li><strong>Número:</strong> #{{ ticket_number }}</li>
|
||||
<li><strong>Asunto:</strong> {{ subject }}</li>
|
||||
<li><strong>Cliente:</strong> {{ customer_name }}</li>
|
||||
<li><strong>Prioridad:</strong> {{ priority }}</li>
|
||||
</ul>
|
||||
<p><a href="{{ ticket_url }}">Ver ticket</a></p>
|
||||
""",
|
||||
"text": """
|
||||
Ticket asignado
|
||||
|
||||
Hola {{ agent_name }},
|
||||
|
||||
Se te ha asignado el ticket:
|
||||
|
||||
Número: #{{ ticket_number }}
|
||||
Asunto: {{ subject }}
|
||||
Cliente: {{ customer_name }}
|
||||
Prioridad: {{ priority }}
|
||||
|
||||
Ver ticket: {{ ticket_url }}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
if template_name not in templates:
|
||||
raise ValueError(f"Template '{template_name}' not found")
|
||||
|
||||
template_data = templates[template_name]
|
||||
|
||||
# Render templates
|
||||
env = Environment(loader=MemoryLoader({
|
||||
f"{template_name}_subject": template_data["subject"],
|
||||
f"{template_name}_html": template_data["html"],
|
||||
f"{template_name}_text": template_data["text"]
|
||||
}))
|
||||
|
||||
subject_template = env.get_template(f"{template_name}_subject")
|
||||
html_template = env.get_template(f"{template_name}_html")
|
||||
text_template = env.get_template(f"{template_name}_text")
|
||||
|
||||
subject = subject_template.render(**context)
|
||||
html_content = html_template.render(**context)
|
||||
text_content = text_template.render(**context)
|
||||
|
||||
# Send email using the basic send task
|
||||
return send_email_task.apply_async(
|
||||
kwargs={
|
||||
"to_email": to_email,
|
||||
"subject": subject,
|
||||
"html_content": html_content,
|
||||
"text_content": text_content,
|
||||
"tenant_id": tenant_id,
|
||||
"correlation_id": correlation_id
|
||||
}
|
||||
).get()
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Failed to send templated email",
|
||||
to_email=to_email,
|
||||
template_name=template_name,
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"to_email": to_email,
|
||||
"template_name": template_name,
|
||||
"error": str(exc)
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=600,
|
||||
soft_time_limit=540
|
||||
)
|
||||
def send_bulk_email_task(
|
||||
self,
|
||||
email_list: List[Dict[str, Any]],
|
||||
tenant_id: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send bulk emails.
|
||||
|
||||
Args:
|
||||
email_list: List of email dicts with to_email, subject, content
|
||||
tenant_id: Tenant ID
|
||||
correlation_id: Correlation ID
|
||||
|
||||
Returns:
|
||||
Dict with bulk send results
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
tenant_id=tenant_id,
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
total_emails = len(email_list)
|
||||
task_logger.info(f"Starting bulk email task", total_emails=total_emails)
|
||||
|
||||
results = []
|
||||
|
||||
for i, email_data in enumerate(email_list):
|
||||
try:
|
||||
result = send_email_task.apply_async(
|
||||
kwargs={
|
||||
**email_data,
|
||||
"tenant_id": tenant_id,
|
||||
"correlation_id": correlation_id
|
||||
}
|
||||
).get()
|
||||
|
||||
results.append(result)
|
||||
|
||||
# Update task progress
|
||||
current_task.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'current': i + 1, 'total': total_emails}
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Failed to send bulk email item",
|
||||
index=i,
|
||||
email_data=email_data,
|
||||
error=str(exc)
|
||||
)
|
||||
|
||||
results.append({
|
||||
"success": False,
|
||||
"to_email": email_data.get("to_email"),
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
# Calculate stats
|
||||
successful = sum(1 for r in results if r.get("success"))
|
||||
failed = total_emails - successful
|
||||
|
||||
task_logger.info(
|
||||
"Bulk email task completed",
|
||||
total=total_emails,
|
||||
successful=successful,
|
||||
failed=failed
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_emails,
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"results": results
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user