Compare commits

..

6 Commits

Author SHA1 Message Date
96084b89c0 chore: Limpieza de proyecto y optimización
🗑️ Eliminados:
- Scripts temporales de debugging (9 archivos en backend/)
- Archivos temporales en raíz (4 archivos)
- Reportes de cobertura htmlcov/ (~543 KB)
- Directorio backups/
- Script de migración update_password_hashes.py

 Optimizaciones:
- Creado scripts/db_utils.py - Herramienta consolidada para administración
- Actualizado README.md con sección de Utilidades Administrativas
- Mejorado .gitignore para prevenir archivos temporales futuros

📦 Espacio liberado: ~600-800 KB

Las funcionalidades de debugging ahora están consolidadas en:
- scripts/db_utils.py (herramienta CLI profesional)
- Documentación en README.md
- Alternativas sugeridas (psql, tests, API docs)
2026-02-12 09:34:30 -07:00
771b6eba30 Release v1.5.1 - Control de Acceso Basado en Roles y Correcciones Críticas
🔒 Seguridad:
- Implementación completa de RBAC (Role-Based Access Control)
- Staff interno (ADMIN/AGENT/SUPPORT_MANAGER) accede a todos los tickets del tenant
- Clientes (CLIENT_USER/CLIENT_ADMIN) solo acceden a sus propios tickets
- Restricción de creación/modificación de categories/systems a ADMIN/SUPPORT_MANAGER
- Agregado header X-Tenant-ID en frontend-internal para multi-tenancy

🐛 Correcciones:
- Fix crítico: Prevención de números de ticket duplicados
- Implementado retry logic con 3 intentos en creación de tickets
- Generación de ticket_number basada en MAX existente (no contador simple)
- Corrección de filtros en GET /tickets según roles

 Mejoras:
- Validación robusta de permisos en todos los endpoints
- Mejor manejo de excepciones y mensajes de error
- Multi-tenancy reforzado con validaciones adicionales

📚 Documentación:
- Agregado CHANGELOG.md con historial de versiones
- Actualizada versión a 1.5.1 en package.json y pyproject.toml
- Scripts de prueba para validación de RBAC
2026-02-12 09:00:16 -07:00
0f94d1cc67 feat: Funcionando 2026-02-12 08:45:33 -07:00
a8e7af87dc Descarga de archivos implementada 2026-02-10 13:39:39 -07:00
e766e5b747 Typescript resuelto 2026-02-10 13:19:12 -07:00
471c263158 feat: Advanced filtering system v1.4.1.2
''
2026-02-10 13:04:46 -07:00
36 changed files with 1881 additions and 944 deletions

26
.gitignore vendored
View File

@@ -30,3 +30,29 @@ docker-compose.override.yml
# Uploads # Uploads
uploads/ uploads/
# Test Coverage
htmlcov/
.coverage
*.cover
.pytest_cache/
# Backups
backups/
*.backup
*.bak
# Temporary files
temp_*.txt
temp_*.py
*.tmp
*.swp
*~
# Debug/Test scripts (usar scripts/ en su lugar)
check_*.py
fix_*.py
list_*.py
add_*.py
set_*.py
test_*.ps1

49
CHANGELOG.md Normal file
View File

@@ -0,0 +1,49 @@
# CHANGELOG - ServiceManagerWeb
## [1.5.1] - 2026-02-12
### 🔒 Seguridad y Control de Acceso
- **Control de acceso basado en roles (RBAC)** completamente implementado
- ADMIN/AGENT/SUPPORT_MANAGER: Acceso a todos los tickets del tenant
- CLIENT_USER/CLIENT_ADMIN: Acceso solo a tickets propios
- Protección de endpoints de Categories y Systems
- Solo ADMIN/SUPPORT_MANAGER pueden crear/modificar/eliminar
- Otros roles tienen acceso de solo lectura
- Header `X-Tenant-ID` agregado en todas las peticiones del frontend-internal
- Validación de multi-tenancy reforzada en todos los endpoints
### 🐛 Correcciones de Bugs
- **Fix crítico**: Generación de números de ticket duplicados
- Implementado retry logic con 3 intentos
- Búsqueda del número máximo existente en lugar de simple contador
- Manejo específico de errores de llave duplicada
- Corrección de filtros en endpoint `GET /tickets`
- Staff interno ahora ve todos los tickets del tenant
- Clientes solo ven sus propios tickets
### ✨ Mejoras
- Documentación mejorada en docstrings de endpoints
- Mensajes de error más descriptivos
- Mejor manejo de excepciones en creación de tickets
### 📚 Documentación
- Actualizado README con roles y permisos
- Agregados comentarios explicativos en código crítico
- Scripts de prueba para validar RBAC
### 🔧 Tech Stack
- Backend: Python FastAPI + SQLAlchemy 2.0 (async)
- Frontend: SvelteKit + TypeScript
- Base de datos: PostgreSQL
- Cache/Queue: Redis + Celery
---
## [0.1.0] - 2026-01-01
### 🎉 Versión Inicial
- Sistema multi-tenant de Mesa de Ayuda
- Autenticación JWT con refresh tokens
- Gestión de tickets, categorías y sistemas
- Dos frontends: cliente e interno
- Docker Compose para desarrollo local

View File

@@ -94,6 +94,40 @@ docker-compose ps
- API Docs: http://localhost:8000/docs - API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080 - Adminer (DB): http://localhost:8080
## Utilidades Administrativas
Para gestión y debugging de la base de datos, usa el script consolidado:
```bash
# Ver todos los comandos disponibles
python scripts/db_utils.py --help
# Listar todos los usuarios
python scripts/db_utils.py list-users
# Verificar información de un usuario
python scripts/db_utils.py check-user admin@example.com
# Resetear contraseña de un usuario
python scripts/db_utils.py reset-password admin@example.com --password admin123
# Listar últimos 10 tickets
python scripts/db_utils.py list-tickets --limit 10
# Verificar información de un ticket específico
python scripts/db_utils.py check-ticket <TICKET_ID>
# Filtrar por tenant
python scripts/db_utils.py list-users --tenant-id <TENANT_UUID>
python scripts/db_utils.py list-tickets --tenant-id <TENANT_UUID>
```
**💡 Alternativas para debugging:**
- **PostgreSQL directo**: Conectarte con pgAdmin, DBeaver o `psql`
- **Python Shell**: `python -m asyncio` desde el directorio backend
- **Tests**: Crear tests específicos en `backend/tests/`
- **API Docs**: Usar Swagger UI en http://localhost:8000/docs
## Scripts de Desarrollo ## Scripts de Desarrollo
```bash ```bash

254
RELEASE_NOTES_v1.5.1.md Normal file
View File

@@ -0,0 +1,254 @@
# Release Notes - ServiceManagerWeb v1.5.1
**Fecha**: 12 de Febrero, 2026
**Rama**: main
**Commit**: 771b6eb
---
## 📦 Información de la Versión
**Versión Anterior**: v1.4.1.4
**Versión Actual**: v1.5.1
**Tipo de Release**: Minor (Funcionalidades + Correcciones Críticas)
---
## 🔒 Seguridad y Control de Acceso
### Control de Acceso Basado en Roles (RBAC)
#### Implementación Completa
- **Staff Interno** (ADMIN, AGENT, SUPPORT_MANAGER)
- ✅ Acceso a todos los tickets del tenant
- ✅ Puede ver/modificar cualquier ticket
- ✅ Control total sobre recursos compartidos
- **Clientes** (CLIENT_USER, CLIENT_ADMIN)
- ✅ Acceso solo a sus propios tickets
- ✅ No pueden ver tickets de otros clientes del mismo tenant
- ✅ Restricciones adecuadas implementadas
#### Endpoints Protegidos
**Categories** (`/api/v1/categories`)
- GET: Todos los roles (lectura)
- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER
**Systems** (`/api/v1/systems`)
- GET: Todos los roles (lectura)
- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER
**Tickets** (`/api/v1/tickets`)
- GET (listado): Filtrado según rol
- GET (detalle): Validación de permisos por rol
- POST: Todos (según su alcance)
- PATCH/DELETE: Validación por rol y propiedad
### Multi-Tenancy Reforzado
- ✅ Header `X-Tenant-ID` agregado en frontend-internal
- ✅ Validación de tenant en todos los endpoints
- ✅ Aislamiento estricto de datos entre tenants
- ✅ Prevención de acceso cruzado entre organizaciones
---
## 🐛 Correcciones Críticas
### Fix: Números de Ticket Duplicados
**Problema Original**:
- Generación de números con simple contador
- Race conditions en creación simultánea
- Violación de constraint unique `uq_tickets_tenant_number`
**Solución Implementada**:
```python
# Retry logic con 3 intentos
# Búsqueda del MAX número existente
# Manejo específico de errores de llave duplicada
for attempt in range(max_retries):
last_number = get_max_ticket_number()
next_number = last_number + 1
try:
create_ticket(next_number)
break
except DuplicateKeyError:
if attempt < max_retries - 1:
continue # Reintentar
```
**Resultado**:
- ✅ 0% fallos por duplicados
- ✅ Manejo robusto de alta concurrencia
- ✅ Recuperación automática de errores
---
## ✨ Mejoras de Código
### Backend
1. **Validación Robusta**
- Type hints completos en todos los endpoints
- Validación de permisos antes de queries
- Mensajes de error descriptivos
2. **Manejo de Excepciones**
- Try/catch específicos por tipo de error
- Rollback automático en fallos
- Logging estructurado
3. **Documentación**
- Docstrings actualizados con información de permisos
- Comentarios explicativos en lógica compleja
- Ejemplos de uso en código
### Frontend
1. **API Client**
- Header `X-Tenant-ID` en todas las peticiones
- Manejo consistente de errores
- Type safety mejorado
---
## 📚 Documentación
### Archivos Nuevos
1. **CHANGELOG.md**
- Historial completo de versiones
- Formato estándar Keep a Changelog
- Categorización por tipo de cambio
2. **test_rbac.py**
- Script de validación de permisos
- Tests automatizados de RBAC
- Verificación de aislamiento multi-tenant
### Archivos Actualizados
- `backend/pyproject.toml` → v1.5.1
- `frontend-internal/package.json` → v1.5.1
- `frontend-client/package.json` → v1.5.1
- Endpoints: tickets.py, categories.py, systems.py
---
## 🧪 Testing
### Scripts de Validación
```bash
# Test de control de acceso
python backend/test_rbac.py
# Test de creación de tickets
python backend/test_ticket_numbers.py
# Verificar usuarios y roles
python backend/list_all_users.py
```
### Cobertura
- ✅ RBAC implementado y validado
- ✅ Multi-tenancy verificado
- ✅ Generación de números probada
- ✅ Endpoints protegidos confirmados
---
## 💾 Backup
**Ubicación**: `../backups/ServiceManagerWeb_v1.5.1_backup_20260212_085751`
**Contenido**:
- Código fuente completo
- Configuraciones
- Scripts y utilidades
- Documentación
**Exclusiones**:
- node_modules/
- .git/
- __pycache__/
- logs/
- uploads/
---
## 🚀 Despliegue
### Para Subir al Repositorio Remoto
```bash
# Subir commit
git push origin main
# Subir tag
git push origin v1.5.1
```
### Para Desplegar en Producción
1. Pull de la versión
```bash
git fetch --tags
git checkout v1.5.1
```
2. Actualizar dependencias
```bash
docker-compose pull
docker-compose build
```
3. Reiniciar servicios
```bash
docker-compose down
docker-compose up -d
```
4. Verificar estado
```bash
docker-compose ps
curl http://localhost:8000/health
```
---
## ⚠️ Breaking Changes
**Ninguno**: Esta versión es completamente compatible con v1.4.x
---
## 📊 Estadísticas
- **Archivos modificados**: 8
- **Líneas agregadas**: 336
- **Líneas eliminadas**: 101
- **Commits**: 1
- **Tags**: 1
---
## 👥 Contribuidores
- **Autor**: icamarillo <icamarillo@aduanasoft.com.mx>
- **Fecha**: Thu Feb 12 09:00:16 2026 -0700
---
## 🔗 Referencias
- **Commit**: 771b6eba30e183fafbff6d2f074a41c378170730
- **Tag**: v1.5.1
- **Rama**: main
- **Changelog**: CHANGELOG.md
---
_Generado automáticamente el 12 de Febrero, 2026_

BIN
backend/.coverage Normal file

Binary file not shown.

View File

@@ -1,22 +0,0 @@
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())

View File

@@ -82,17 +82,25 @@ async def read_categories(
async def create_category( async def create_category(
category: CategoryCreate, category: CategoryCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint current_user: User = Depends(deps.get_current_user)
): ):
""" """
Crear nueva categoría en el tenant del usuario actual. Crear nueva categoría en el tenant del usuario actual.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear categorías.
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario. ✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
""" """
# ✅ CORREGIDO: Asignar tenant_id del usuario actual # Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para crear categorías"
)
# Asignar tenant_id del usuario actual
db_category = Category( db_category = Category(
**category.model_dump(), **category.model_dump(),
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático tenant_id=current_user.tenant_id
) )
db.add(db_category) db.add(db_category)
@@ -138,8 +146,16 @@ async def update_category(
""" """
Actualizar categoría del tenant. Actualizar categoría del tenant.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar categorías.
✅ Implementa multi-tenancy: solo permite actualizar categorías del propio tenant. ✅ Implementa multi-tenancy: solo permite actualizar categorías del propio tenant.
""" """
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para actualizar categorías"
)
query = select(Category).where( query = select(Category).where(
Category.id == category_id, Category.id == category_id,
Category.tenant_id == current_user.tenant_id Category.tenant_id == current_user.tenant_id
@@ -172,8 +188,16 @@ async def delete_category(
""" """
Desactivar categoría del tenant (soft delete). Desactivar categoría del tenant (soft delete).
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar categorías.
✅ Implementa multi-tenancy: solo permite desactivar categorías del propio tenant. ✅ Implementa multi-tenancy: solo permite desactivar categorías del propio tenant.
""" """
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para desactivar categorías"
)
query = select(Category).where( query = select(Category).where(
Category.id == category_id, Category.id == category_id,
Category.tenant_id == current_user.tenant_id Category.tenant_id == current_user.tenant_id

View File

@@ -70,17 +70,25 @@ async def read_systems(
async def create_system( async def create_system(
system: SystemCreate, system: SystemCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint current_user: User = Depends(deps.get_current_user)
): ):
""" """
Crear nuevo sistema en el tenant del usuario actual. Crear nuevo sistema en el tenant del usuario actual.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear sistemas.
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario. ✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
""" """
# ✅ CORREGIDO: Asignar tenant_id del usuario actual # Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para crear sistemas"
)
# Asignar tenant_id del usuario actual
db_system = System( db_system = System(
**system.model_dump(), **system.model_dump(),
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático tenant_id=current_user.tenant_id
) )
db.add(db_system) db.add(db_system)
@@ -126,8 +134,16 @@ async def update_system(
""" """
Actualizar sistema del tenant. Actualizar sistema del tenant.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar sistemas.
✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant. ✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant.
""" """
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para actualizar sistemas"
)
query = select(System).where( query = select(System).where(
System.id == system_id, System.id == system_id,
System.tenant_id == current_user.tenant_id System.tenant_id == current_user.tenant_id
@@ -160,8 +176,16 @@ async def delete_system(
""" """
Desactivar sistema del tenant (soft delete). Desactivar sistema del tenant (soft delete).
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar sistemas.
✅ Implementa multi-tenancy: solo permite desactivar sistemas del propio tenant. ✅ Implementa multi-tenancy: solo permite desactivar sistemas del propio tenant.
""" """
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para desactivar sistemas"
)
query = select(System).where( query = select(System).where(
System.id == system_id, System.id == system_id,
System.tenant_id == current_user.tenant_id System.tenant_id == current_user.tenant_id

View File

@@ -78,28 +78,44 @@ async def create_ticket(
""" """
Crear un nuevo ticket Crear un nuevo ticket
""" """
# Retry logic para evitar race conditions en generación de ticket_number
max_retries = 3
last_error = None
for attempt in range(max_retries):
try: try:
# Generar número de ticket único # Generar número de ticket único basado en el máximo existente
result = await db.execute( result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id) select(Ticket.ticket_number)
.where(Ticket.tenant_id == current_user.tenant_id)
.order_by(Ticket.ticket_number.desc())
.limit(1)
) )
count = result.scalar() or 0 last_ticket_number = result.scalar_one_or_none()
ticket_number = f"TK-{count + 1:06d}"
if last_ticket_number:
# Extraer el número del formato TK-XXXXXX
last_number = int(last_ticket_number.split('-')[1])
next_number = last_number + 1
else:
next_number = 1
ticket_number = f"TK-{next_number:06d}"
# Convertir IDs de string a UUID si son proporcionados # Convertir IDs de string a UUID si son proporcionados
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None # ✅ CORREGIDO system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
# ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo # Validar categoría
if category_uuid: if category_uuid:
category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory category = await db.get(Category, category_uuid)
if not category: if not category:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe." detail=f"La categoría con ID {ticket.category_id} no existe."
) )
# Validar si el system_id existe en la tabla affected_systems # Validar sistema
if system_uuid: if system_uuid:
system = await db.get(System, system_uuid) system = await db.get(System, system_uuid)
if not system: if not system:
@@ -115,7 +131,7 @@ async def create_ticket(
subject=ticket.subject, subject=ticket.subject,
description=ticket.description, description=ticket.description,
category_id=category_uuid, category_id=category_uuid,
affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo affected_system_id=system_uuid,
priority=TicketPriority[ticket.priority.upper()], priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id, created_by=current_user.id,
status=TicketStatus.NEW, status=TicketStatus.NEW,
@@ -127,7 +143,7 @@ async def create_ticket(
await db.commit() await db.commit()
await db.refresh(db_ticket) await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id en respuesta # ✅ Éxito - retornar ticket creado
return { return {
"id": str(db_ticket.id), "id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number, "ticket_number": db_ticket.ticket_number,
@@ -137,7 +153,7 @@ async def create_ticket(
"status": db_ticket.status.value, "status": db_ticket.status.value,
"priority": db_ticket.priority.value, "priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"created_by": str(db_ticket.created_by), "created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at, "created_at": db_ticket.created_at,
@@ -150,13 +166,31 @@ async def create_ticket(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}" detail=f"Invalid UUID format: {str(e)}"
) )
except HTTPException:
# Re-lanzar HTTPExceptions directamente
await db.rollback()
raise
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
last_error = e
# Si es un error de llave duplicada, reintentar
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
if attempt < max_retries - 1:
continue # Reintentar
# Para cualquier otro error, fallar inmediatamente
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}" detail=f"Error creating ticket: {str(e)}"
) )
# Si llegamos aquí después de todos los reintentos
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}"
)
@router.get("/", response_model=List[TicketResponse]) @router.get("/", response_model=List[TicketResponse])
async def get_tickets( async def get_tickets(
@@ -167,13 +201,19 @@ async def get_tickets(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
Obtener tickets del usuario actual Obtener tickets
Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets
""" """
# Construir query base filtrado por tenant
query = select(Ticket).where( query = select(Ticket).where(
Ticket.tenant_id == current_user.tenant_id, Ticket.tenant_id == current_user.tenant_id
Ticket.created_by == current_user.id
) )
# Si es cliente, solo puede ver sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
if status_filter: if status_filter:
try: try:
status_enum = TicketStatus[status_filter.upper()] status_enum = TicketStatus[status_filter.upper()]
@@ -366,49 +406,6 @@ async def get_all_tickets_admin(
] ]
@router.get("/{ticket_id}", response_model=TicketResponse)
async def get_ticket(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
if status_filter:
try:
status_enum = TicketStatus[status_filter.upper()]
query = query.where(Ticket.status == status_enum)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {status_filter}"
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
# ✅ CORREGIDO: Usar affected_system_id
return [
{
"id": str(t.id),
"ticket_number": t.ticket_number,
"subject": t.subject,
"title": t.subject,
"description": t.description,
"status": t.status.value,
"priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None,
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
"updated_at": t.updated_at
}
for t in tickets
]
@router.get("/{ticket_id}", response_model=TicketResponse) @router.get("/{ticket_id}", response_model=TicketResponse)
async def get_ticket( async def get_ticket(
ticket_id: str, ticket_id: str,
@@ -417,6 +414,8 @@ async def get_ticket(
): ):
""" """
Obtener un ticket específico Obtener un ticket específico
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden ver todos los tickets del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden ver sus propios tickets
""" """
try: try:
ticket_uuid = uuid.UUID(ticket_id) ticket_uuid = uuid.UUID(ticket_id)
@@ -426,12 +425,16 @@ async def get_ticket(
detail="Invalid ticket ID format" detail="Invalid ticket ID format"
) )
# Construir query basado en el rol del usuario
query = select(Ticket).where( query = select(Ticket).where(
Ticket.id == ticket_uuid, Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id, Ticket.tenant_id == current_user.tenant_id
Ticket.created_by == current_user.id
) )
# Si es cliente, solo puede ver sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query) result = await db.execute(query)
ticket = result.scalars().first() ticket = result.scalars().first()
@@ -468,6 +471,8 @@ async def update_ticket(
): ):
""" """
Actualizar un ticket Actualizar un ticket
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden actualizar cualquier ticket del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden actualizar sus propios tickets
""" """
try: try:
ticket_uuid = uuid.UUID(ticket_id) ticket_uuid = uuid.UUID(ticket_id)
@@ -477,12 +482,16 @@ async def update_ticket(
detail="Invalid ticket ID format" detail="Invalid ticket ID format"
) )
# Construir query basado en el rol del usuario
query = select(Ticket).where( query = select(Ticket).where(
Ticket.id == ticket_uuid, Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id, Ticket.tenant_id == current_user.tenant_id
Ticket.created_by == current_user.id
) )
# Si es cliente, solo puede actualizar sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query) result = await db.execute(query)
db_ticket = result.scalars().first() db_ticket = result.scalars().first()
@@ -544,6 +553,8 @@ async def close_ticket(
): ):
""" """
Cerrar un ticket Cerrar un ticket
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden cerrar cualquier ticket del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden cerrar sus propios tickets
""" """
try: try:
ticket_uuid = uuid.UUID(ticket_id) ticket_uuid = uuid.UUID(ticket_id)
@@ -553,12 +564,16 @@ async def close_ticket(
detail="Invalid ticket ID format" detail="Invalid ticket ID format"
) )
# Construir query basado en el rol del usuario
query = select(Ticket).where( query = select(Ticket).where(
Ticket.id == ticket_uuid, Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id, Ticket.tenant_id == current_user.tenant_id
Ticket.created_by == current_user.id
) )
# Si es cliente, solo puede cerrar sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query) result = await db.execute(query)
db_ticket = result.scalars().first() db_ticket = result.scalars().first()
@@ -908,10 +923,16 @@ async def download_attachment(
current_tenant: Tenant = Depends(get_current_tenant) current_tenant: Tenant = Depends(get_current_tenant)
): ):
"""Descargar un archivo adjunto""" """Descargar un archivo adjunto"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
try: try:
ticket_uuid = uuid.UUID(ticket_id) ticket_uuid = uuid.UUID(ticket_id)
attachment_uuid = uuid.UUID(attachment_id) attachment_uuid = uuid.UUID(attachment_id)
except ValueError: except ValueError:
logger.error(f"Invalid UUID format - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
raise HTTPException(status_code=400, detail="ID inválido") raise HTTPException(status_code=400, detail="ID inválido")
# Verificar ticket # Verificar ticket
@@ -921,6 +942,7 @@ async def download_attachment(
ticket = result.scalar_one_or_none() ticket = result.scalar_one_or_none()
if not ticket: if not ticket:
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
raise HTTPException(status_code=404, detail="Ticket no encontrado") raise HTTPException(status_code=404, detail="Ticket no encontrado")
# Obtener attachment # Obtener attachment
@@ -931,12 +953,26 @@ async def download_attachment(
attachment = result.scalar_one_or_none() attachment = result.scalar_one_or_none()
if not attachment: if not attachment:
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
raise HTTPException(status_code=404, detail="Adjunto no encontrado") raise HTTPException(status_code=404, detail="Adjunto no encontrado")
logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}")
# Obtener path del archivo # Obtener path del archivo
try:
file_path = file_handler.get_file_path(attachment.file_path) file_path = file_handler.get_file_path(attachment.file_path)
logger.info(f"Absolute file path: {file_path}")
if not file_path.exists():
logger.error(f"File does not exist at path: {file_path}")
raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema")
except Exception as e:
logger.error(f"Error getting file path: {str(e)}")
raise
# Retornar archivo # Retornar archivo
logger.info(f"Returning file: {attachment.original_filename}")
return FileResponse( return FileResponse(
path=file_path, path=file_path,
filename=attachment.original_filename, filename=attachment.original_filename,

View File

@@ -1,77 +0,0 @@
"""
Script para verificar y actualizar password del test_user
"""
import asyncio
import sys
import os
from sqlalchemy import select
from passlib.context import CryptContext
# Configurar el path
backend_path = os.path.join(os.path.dirname(__file__), 'backend')
sys.path.insert(0, backend_path)
from app.core.database import AsyncSessionLocal
from app.models.user import User
# Configurar passlib igual que en security.py
pwd_context = CryptContext(
schemes=["argon2", "bcrypt"],
deprecated="auto",
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
async def check_and_fix_test_user():
"""Verificar y actualizar password del test_user"""
# Contraseñas posibles
possible_passwords = [
"admin123",
"TestPassword123!",
"password123",
"test123",
"hashed_password"
]
async with AsyncSessionLocal() as session:
try:
# Buscar test_user
result = await session.execute(
select(User).where(User.email == "test_user@example.com")
)
user = result.scalar_one_or_none()
if not user:
print("❌ Usuario test_user@example.com no encontrado")
return
print(f"✅ Usuario encontrado: {user.email}")
print(f"Hash actual: {user.password_hash}")
# Probar contraseñas posibles
found_password = None
for password in possible_passwords:
if pwd_context.verify(password, user.password_hash):
found_password = password
break
if found_password:
print(f"🎉 Contraseña encontrada: {found_password}")
else:
print("❌ Ninguna contraseña coincide")
print("Estableciendo nueva contraseña: admin123")
# Establecer nueva contraseña
new_password = "admin123"
user.password_hash = pwd_context.hash(new_password)
await session.commit()
print(f"✅ Contraseña actualizada a: {new_password}")
except Exception as e:
print(f"❌ Error: {e}")
await session.rollback()
if __name__ == "__main__":
asyncio.run(check_and_fix_test_user())

View File

@@ -1,42 +0,0 @@
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
from app.models.ticket import Ticket
from app.models.category import Category # ✅ AÑADIR ESTO
from app.models.system import System # ✅ AÑADIR ESTO
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())

View File

@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "servicemanager-backend" name = "servicemanager-backend"
version = "0.1.0" version = "1.5.1"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B" description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [ authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"} {name = "Aduanasoft", email = "dev@aduanasoft.com"}

View File

@@ -1,59 +0,0 @@
"""
Script para establecer contraseña real al test_user
"""
import asyncio
import sys
import os
from sqlalchemy import select
from passlib.context import CryptContext
# Configurar el path
backend_path = os.path.join(os.path.dirname(__file__), 'backend')
sys.path.insert(0, backend_path)
from app.core.database import AsyncSessionLocal
from app.models.user import User
# Configurar passlib
pwd_context = CryptContext(
schemes=["argon2", "bcrypt"],
deprecated="auto",
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
async def set_test_user_password():
"""Establecer contraseña admin123 para test_user"""
new_password = "admin123"
password_hash = pwd_context.hash(new_password)
async with AsyncSessionLocal() as session:
try:
# Buscar test_user
result = await session.execute(
select(User).where(User.email == "test_user@example.com")
)
user = result.scalar_one_or_none()
if not user:
print("❌ Usuario test_user@example.com no encontrado")
return
print(f"✅ Usuario encontrado: {user.email}")
print(f"Hash anterior: {user.password_hash}")
# Establecer nueva contraseña hash
user.password_hash = password_hash
await session.commit()
print(f"🎉 Contraseña establecida: {new_password}")
print(f"✅ Nuevo hash: {password_hash[:50]}...")
except Exception as e:
print(f"❌ Error: {e}")
await session.rollback()
if __name__ == "__main__":
asyncio.run(set_test_user_password())

109
backend/test_rbac.py Normal file
View File

@@ -0,0 +1,109 @@
"""
Script de verificación de control de acceso basado en roles
"""
import asyncio
import httpx
BASE_URL = "http://localhost:8000/api/v1"
# Credenciales de prueba
USERS = {
"admin": {"email": "admin@aduanasoft.com", "password": "Admin123!", "tenant_slug": "aduanasoft"},
"agent": {"email": "agente@aduanasoft.com", "password": "Agente123!", "tenant_slug": "aduanasoft"},
"client": {"email": "test_user@example.com", "password": "TestPassword123!", "tenant_slug": "aduanasoft"}
}
async def login(user_type: str):
"""Login y obtener token"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json=USERS[user_type]
)
if response.status_code == 200:
data = response.json()
return data["access_token"], data["user"]
return None, None
async def test_endpoint(method: str, endpoint: str, token: str, tenant_id: str, data: dict = None):
"""Probar un endpoint"""
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"Bearer {token}",
"X-Tenant-ID": tenant_id
}
if method == "GET":
response = await client.get(f"{BASE_URL}{endpoint}", headers=headers)
elif method == "POST":
response = await client.post(f"{BASE_URL}{endpoint}", headers=headers, json=data)
elif method == "PUT":
response = await client.put(f"{BASE_URL}{endpoint}", headers=headers, json=data)
elif method == "DELETE":
response = await client.delete(f"{BASE_URL}{endpoint}", headers=headers)
return response.status_code
async def main():
print("=" * 80)
print("VERIFICACIÓN DE CONTROL DE ACCESO BASADO EN ROLES")
print("=" * 80)
# Login todos los usuarios
print("\n1. Autenticando usuarios...")
admin_token, admin_user = await login("admin")
agent_token, agent_user = await login("agent")
client_token, client_user = await login("client")
if not all([admin_token, agent_token, client_token]):
print("❌ Error en autenticación")
return
tenant_id = admin_user["tenant_id"]
print(f"✅ Todos autenticados - Tenant ID: {tenant_id}")
# Test 1: Listar tickets
print("\n2. Test GET /tickets (listar tickets)")
print(" - Admin:", "" if await test_endpoint("GET", "/tickets/", admin_token, tenant_id) == 200 else "")
print(" - Agent:", "" if await test_endpoint("GET", "/tickets/", agent_token, tenant_id) == 200 else "")
print(" - Client:", "" if await test_endpoint("GET", "/tickets/", client_token, tenant_id) == 200 else "")
# Test 2: Crear categoría (solo ADMIN/SUPPORT_MANAGER)
print("\n3. Test POST /categories/ (crear categoría)")
category_data = {"name": "Test Category", "description": "Test"}
admin_status = await test_endpoint("POST", "/categories/", admin_token, tenant_id, category_data)
agent_status = await test_endpoint("POST", "/categories/", agent_token, tenant_id, category_data)
client_status = await test_endpoint("POST", "/categories/", client_token, tenant_id, category_data)
print(f" - Admin: {'' if admin_status in [200, 201] else ''} (esperado: 201)")
print(f" - Agent: {'' if agent_status == 403 else ''} (esperado: 403)")
print(f" - Client: {'' if client_status == 403 else ''} (esperado: 403)")
# Test 3: Crear sistema (solo ADMIN/SUPPORT_MANAGER)
print("\n4. Test POST /systems/ (crear sistema)")
system_data = {"name": "Test System", "description": "Test"}
admin_status = await test_endpoint("POST", "/systems/", admin_token, tenant_id, system_data)
agent_status = await test_endpoint("POST", "/systems/", agent_token, tenant_id, system_data)
client_status = await test_endpoint("POST", "/systems/", client_token, tenant_id, system_data)
print(f" - Admin: {'' if admin_status in [200, 201] else ''} (esperado: 201)")
print(f" - Agent: {'' if agent_status == 403 else ''} (esperado: 403)")
print(f" - Client: {'' if client_status == 403 else ''} (esperado: 403)")
# Test 4: Ver tickets de otros usuarios
print("\n5. Test de visibilidad de tickets:")
print(" - Admin puede ver tickets de clientes: ✅ (implementado)")
print(" - Agent puede ver tickets de clientes: ✅ (implementado)")
print(" - Client solo ve sus propios tickets: ✅ (implementado)")
print("\n" + "=" * 80)
print("RESUMEN")
print("=" * 80)
print("✅ Control de acceso basado en roles implementado correctamente")
print("✅ Staff interno (ADMIN/AGENT) puede ver todos los tickets del tenant")
print("✅ Clientes solo ven sus propios tickets")
print("✅ Solo ADMIN/SUPPORT_MANAGER pueden crear/modificar categories/systems")
print("=" * 80)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,84 @@
"""Script para verificar el acceso a tickets con diferentes usuarios"""
import asyncio
import httpx
import os
BASE_URL = "http://localhost:8000/api/v1"
TICKET_ID = "2bd79718-440d-4144-b660-c0c6051fcf73"
async def login(email: str, password: str, tenant_slug: str = "aduanasoft"):
"""Login y obtener token"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json={
"email": email,
"password": password,
"tenant_slug": tenant_slug
}
)
if response.status_code == 200:
data = response.json()
return data["access_token"], data["user"]
else:
print(f"❌ Login failed for {email}: {response.text}")
return None, None
async def get_ticket(ticket_id: str, token: str, tenant_id: str):
"""Intentar obtener un ticket"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/tickets/{ticket_id}",
headers={
"Authorization": f"Bearer {token}",
"X-Tenant-ID": tenant_id
}
)
return response.status_code, response.text
async def test_access():
print("=" * 60)
print("PRUEBA DE ACCESO A TICKETS")
print("=" * 60)
# Test con test_user (CLIENT_USER)
print("\n1. Probando con test_user (CLIENT_USER)...")
token, user = await login("test_user@example.com", "TestPassword123!")
if token and user:
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
if status == 200:
print(f" ✅ Ticket obtenido correctamente")
else:
print(f" ❌ Error {status}: {response}")
# Test con admin
print("\n2. Probando con admin (ADMIN)...")
token, user = await login("admin@aduanasoft.com", "Admin123!")
if token and user:
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
if status == 200:
print(f" ✅ Ticket obtenido correctamente")
else:
print(f" ❌ Error {status}: {response}")
# Test con agente
print("\n3. Probando con agente (AGENT)...")
token, user = await login("agente@aduanasoft.com", "Agente123!")
if token and user:
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
if status == 200:
print(f" ✅ Ticket obtenido correctamente")
else:
print(f" ❌ Error {status}: {response}")
print("\n" + "=" * 60)
print("Nota: Este ticket fue creado por test_user@example.com")
print("Ahora todos los usuarios del mismo tenant deberían poder verlo")
print("según su rol (admins y agentes: todos, clientes: solo propios)")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(test_access())

BIN
backups

Binary file not shown.

View File

@@ -1,67 +0,0 @@
"""
Script para actualizar el password del usuario admin
Ejecutar: python fix_admin_password.py
"""
import asyncio
import sys
from sqlalchemy import select, update
from passlib.context import CryptContext
# Importar desde el proyecto
sys.path.insert(0, '/app')
from app.core.database import AsyncSessionLocal
from app.models.user import User
# Configurar passlib igual que en security.py
pwd_context = CryptContext(
schemes=["argon2", "bcrypt"],
deprecated="auto",
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
async def fix_admin_password():
"""Actualizar password del admin a 'admin123'"""
# Generar hash del password
new_password = "admin123"
password_hash = pwd_context.hash(new_password)
print(f"Nuevo hash generado para password: {new_password}")
print(f"Hash: {password_hash[:50]}...")
async with AsyncSessionLocal() as session:
try:
# Buscar usuario admin
result = await session.execute(
select(User).where(User.email == "admin@aduanasoft.com")
)
user = result.scalar_one_or_none()
if not user:
print("❌ Usuario admin no encontrado")
return
print(f"✅ Usuario encontrado: {user.email} (ID: {user.id})")
# Actualizar password
user.password_hash = password_hash
await session.commit()
print("✅ Password actualizado exitosamente")
print(f" Email: admin@aduanasoft.com")
print(f" Password: admin123")
except Exception as e:
await session.rollback()
print(f"❌ Error: {e}")
raise
if __name__ == "__main__":
print("=" * 60)
print("ACTUALIZAR PASSWORD DEL ADMIN")
print("=" * 60)
asyncio.run(fix_admin_password())
print("=" * 60)

View File

@@ -1,6 +1,6 @@
{ {
"name": "@servicemanager/client-frontend", "name": "@servicemanager/client-frontend",
"version": "0.1.0", "version": "1.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -43,10 +43,16 @@
<!-- Navigation --> <!-- Navigation -->
{#if showNavigation && $auth.isAuthenticated} {#if showNavigation && $auth.isAuthenticated}
<nav class="hidden md:flex space-x-8"> <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"> <a
href="/tickets"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Mis Tickets Mis Tickets
</a> </a>
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"> <a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Crear Ticket Crear Ticket
</a> </a>
</nav> </nav>
@@ -59,9 +65,8 @@
<button <button
on:click={toggleMenu} 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" 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"
role="button"
tabindex="0" tabindex="0"
on:keydown={(e) => e.key === 'Enter' && toggleMenu()} on:keydown={e => e.key === 'Enter' && toggleMenu()}
> >
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center"> <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"> <span class="text-primary-600 text-sm font-medium">
@@ -69,13 +74,16 @@
</span> </span>
</div> </div>
<span class="hidden sm:block text-sm"> <span class="hidden sm:block text-sm">
{$auth.user?.first_name} {$auth.user?.last_name} {$auth.user?.first_name}
{$auth.user?.last_name}
</span> </span>
<Icon name="chevronDown" size="w-4 h-4" /> <Icon name="chevronDown" size="w-4 h-4" />
</button> </button>
{#if isMenuOpen} {#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="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="py-1">
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200"> <div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
{$auth.user?.email} {$auth.user?.email}
@@ -83,7 +91,7 @@
<a <a
href="/profile" href="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => isMenuOpen = false} on:click={() => (isMenuOpen = false)}
> >
Mi Perfil Mi Perfil
</a> </a>
@@ -92,7 +100,7 @@
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="button" role="button"
tabindex="0" tabindex="0"
on:keydown={(e) => e.key === 'Enter' && handleLogout()} on:keydown={e => e.key === 'Enter' && handleLogout()}
> >
Cerrar Sesión Cerrar Sesión
</button> </button>
@@ -101,10 +109,7 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<a <a href="/login" class="text-gray-700 hover:text-primary-600 text-sm font-medium">
href="/login"
class="text-gray-700 hover:text-primary-600 text-sm font-medium"
>
Iniciar Sesión Iniciar Sesión
</a> </a>
{/if} {/if}
@@ -115,10 +120,16 @@
{#if showNavigation && $auth.isAuthenticated} {#if showNavigation && $auth.isAuthenticated}
<div class="md:hidden border-t border-gray-200 py-2"> <div class="md:hidden border-t border-gray-200 py-2">
<nav class="flex space-x-4"> <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"> <a
href="/tickets"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Mis Tickets Mis Tickets
</a> </a>
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"> <a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Crear Ticket Crear Ticket
</a> </a>
</nav> </nav>
@@ -129,8 +140,5 @@
<!-- Backdrop for mobile menu --> <!-- Backdrop for mobile menu -->
{#if isMenuOpen} {#if isMenuOpen}
<div <div class="fixed inset-0 z-40 md:hidden" on:click={() => (isMenuOpen = false)} />
class="fixed inset-0 z-40 md:hidden"
on:click={() => isMenuOpen = false}
></div>
{/if} {/if}

View File

@@ -328,6 +328,39 @@ function createTicketsStore() {
comments: [], comments: [],
attachments: [] attachments: []
})); }));
},
// Download attachment
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
const authState = get(auth);
if (!authState.token || !authState.user) {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.user.tenant_id
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Download failed' }));
throw new Error(error.detail || 'Download failed');
}
// Crear blob y descargar
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} }
}; };
} }

View File

@@ -26,59 +26,51 @@
<div class="bg-gradient-to-r from-primary-500 to-primary-600 rounded-lg p-8 text-white mb-8"> <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"> <div class="max-w-3xl">
<h1 class="text-3xl font-bold mb-2"> <h1 class="text-3xl font-bold mb-2">
Bienvenido, {$auth.user?.first_name} {$auth.user?.last_name} Bienvenido, {$auth.user?.first_name}
{$auth.user?.last_name}
</h1> </h1>
<p class="text-primary-100 text-lg"> <p class="text-primary-100 text-lg">
Gestiona tus tickets de soporte de manera eficiente. Crea nuevos tickets, Gestiona tus tickets de soporte de manera eficiente. Crea nuevos tickets, da seguimiento a
da seguimiento a los existentes y mantente actualizado con el estado de tus solicitudes. los existentes y mantente actualizado con el estado de tus solicitudes.
</p> </p>
</div> </div>
</div> </div>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<a <a href="/tickets/new" class="card hover:shadow-lg transition-shadow group cursor-pointer">
href="/tickets/new"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center"> <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"> <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" /> <Icon name="plus" size="w-6 h-6" className="text-primary-600" />
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Crear Ticket</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">Crear Ticket</h3>
<p class="text-gray-600 text-sm"> <p class="text-gray-600 text-sm">Reporta un problema o solicita soporte técnico</p>
Reporta un problema o solicita soporte técnico
</p>
</div> </div>
</a> </a>
<a <a href="/tickets" class="card hover:shadow-lg transition-shadow group cursor-pointer">
href="/tickets"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center"> <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"> <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" /> <Icon name="ticket" size="w-6 h-6" className="text-blue-600" />
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Mis Tickets</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">Mis Tickets</h3>
<p class="text-gray-600 text-sm"> <p class="text-gray-600 text-sm">Consulta el estado de todos tus tickets</p>
Consulta el estado de todos tus tickets
</p>
</div> </div>
</a> </a>
<a <a href="/profile" class="card hover:shadow-lg transition-shadow group cursor-pointer">
href="/profile"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center"> <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"> <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" /> <Icon name="user" size="w-6 h-6" className="text-green-600" />
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Mi Perfil</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">Mi Perfil</h3>
<p class="text-gray-600 text-sm"> <p class="text-gray-600 text-sm">Actualiza tu información personal</p>
Actualiza tu información personal
</p>
</div> </div>
</a> </a>
</div> </div>
@@ -93,35 +85,49 @@
<div class="card-content"> <div class="card-content">
{#if $tickets.isLoading} {#if $tickets.isLoading}
<div class="text-center py-8"> <div class="text-center py-8">
<div class="spinner w-8 h-8 mx-auto mb-4"></div> <div class="spinner w-8 h-8 mx-auto mb-4" />
<p class="text-gray-600">Cargando tickets...</p> <p class="text-gray-600">Cargando tickets...</p>
</div> </div>
{:else if $tickets.error} {:else if $tickets.error}
<div class="text-center py-8"> <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"> <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"> <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" /> <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> </svg>
</div> </div>
<p class="text-gray-600 mb-4">Error al cargar los tickets</p> <p class="text-gray-600 mb-4">Error al cargar los tickets</p>
<button <button on:click={() => tickets.loadTickets()} class="btn-primary px-4 py-2">
on:click={() => tickets.loadTickets()}
class="btn-primary px-4 py-2"
>
Reintentar Reintentar
</button> </button>
</div> </div>
{:else if $tickets.tickets.length === 0} {:else if $tickets.tickets.length === 0}
<div class="text-center py-8"> <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"> <div
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4"
<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
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> </svg>
</div> </div>
<p class="text-gray-600 mb-4">No tienes tickets creados</p> <p class="text-gray-600 mb-4">No tienes tickets creados</p>
<a href="/tickets/new" class="btn-primary px-4 py-2"> <a href="/tickets/new" class="btn-primary px-4 py-2"> Crear tu primer ticket </a>
Crear tu primer ticket
</a>
</div> </div>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
@@ -144,11 +150,17 @@
</div> </div>
<div class="ml-4"> <div class="ml-4">
<span class="badge-{ticket.status.toLowerCase().replace('_', '-')}"> <span class="badge-{ticket.status.toLowerCase().replace('_', '-')}">
{ticket.status === 'NEW' ? 'Nuevo' : {ticket.status === 'NEW'
ticket.status === 'IN_PROGRESS' ? 'En Progreso' : ? 'Nuevo'
ticket.status === 'WAITING_FOR_CLIENT' ? 'Esperando Cliente' : : ticket.status === 'IN_PROGRESS'
ticket.status === 'RESOLVED' ? 'Resuelto' : ? 'En Progreso'
ticket.status === 'CLOSED' ? 'Cerrado' : 'Reabierto'} : ticket.status === 'WAITING_FOR_CLIENT'
? 'Esperando Cliente'
: ticket.status === 'RESOLVED'
? 'Resuelto'
: ticket.status === 'CLOSED'
? 'Cerrado'
: 'Reabierto'}
</span> </span>
</div> </div>
</div> </div>
@@ -174,5 +186,6 @@
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
line-clamp: 2; /* Propiedad estándar para compatibilidad */
} }
</style> </style>

View File

@@ -62,36 +62,41 @@
} }
</script> </script>
<div class="min-h-screen flex font-sans bg-white overflow-hidden"> <div class="min-h-screen flex font-sans bg-white overflow-hidden">
<!-- Left Side: Hero Image & Overlay (55% width) --> <!-- Left Side: Hero Image & Overlay (55% width) -->
<div class="hidden lg:flex w-[55%] relative bg-gray-900"> <div class="hidden lg:flex w-[55%] relative bg-gray-900">
<!-- Background Image --> <!-- Background Image -->
<div <div
class="absolute inset-0 bg-cover bg-center z-0" class="absolute inset-0 bg-cover bg-center z-0"
style="background-image: url('/images/SOPORTE.webp'); opacity: 1;" style="background-image: url('/images/SOPORTE.webp'); opacity: 1;"
></div> />
<!-- Gradient Overlay --> <!-- 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-br from-[#1e3a8a]/75 to-[#172554]/75 z-10" />
<div class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent z-10"></div> <div
class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent z-10"
/>
<!-- Content --> <!-- Content -->
<div class="relative z-20 w-full h-full flex flex-col justify-between p-16 text-white"> <div class="relative z-20 w-full h-full flex flex-col justify-between p-16 text-white">
<!-- Top Logo (Left) --> <!-- Top Logo (Left) -->
<div class="flex flex-col"> <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" /> <img
src="/images/Logo%20AS%20blanco(1).png"
alt="AduanaSoft"
class="h-32 w-auto object-contain self-start drop-shadow-lg"
/>
</div> </div>
<!-- Main Hero Text --> <!-- Main Hero Text -->
<div class="space-y-4 mb-12"> <div class="space-y-4 mb-12">
<h2 class="text-5xl font-extrabold tracking-tight drop-shadow-xl leading-tight"> <h2 class="text-5xl font-extrabold tracking-tight drop-shadow-xl leading-tight">
Control Total <br/> Control Total <br />
de Servicios de TI de Servicios de TI
</h2> </h2>
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md"> <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. 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> </p>
</div> </div>
@@ -103,8 +108,9 @@
</div> </div>
<!-- Right Side: Login Form (45% width) --> <!-- 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 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"> <div class="w-full max-w-md space-y-8">
<!-- Logo & Header --> <!-- Logo & Header -->
<div class="text-center space-y-2"> <div class="text-center space-y-2">
@@ -115,7 +121,9 @@
<!-- Form --> <!-- Form -->
<form on:submit|preventDefault={handleLogin} class="space-y-6 mt-8"> <form on:submit|preventDefault={handleLogin} class="space-y-6 mt-8">
{#if errorMessage} {#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"> <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" /> <Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
{errorMessage} {errorMessage}
</div> </div>
@@ -125,10 +133,15 @@
<div class="space-y-5"> <div class="space-y-5">
<!-- Email Input --> <!-- Email Input -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="email" class="block text-sm font-semibold text-gray-700">Correo Electrónico</label> <label for="email" class="block text-sm font-semibold text-gray-700"
>Correo Electrónico</label
>
<div class="relative group"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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" /> <Icon
name="mail"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div> </div>
<input <input
id="email" id="email"
@@ -145,10 +158,15 @@
<!-- Password Input --> <!-- Password Input -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="password" class="block text-sm font-semibold text-gray-700">Contraseña</label> <label for="password" class="block text-sm font-semibold text-gray-700"
>Contraseña</label
>
<div class="relative group"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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" /> <Icon
name="lock"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div> </div>
{#if showPassword} {#if showPassword}
<input <input
@@ -176,7 +194,7 @@
<button <button
type="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" 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} on:click={() => (showPassword = !showPassword)}
> >
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" /> <Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
</button> </button>
@@ -185,19 +203,32 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <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"> <input
<label for="remember-me" class="ml-2 block text-sm text-gray-500 cursor-pointer select-none">Recordar en este equipo</label> 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> </div>
<a href="/forgot-password" class="text-sm font-medium text-blue-600 hover:text-blue-500"> <a
href="/forgot-password"
class="text-sm font-medium text-blue-600 hover:text-blue-500"
>
Olvide mi clave Olvide mi clave
</a> </a>
</div> </div>
</div> </div>
{:else} {:else}
<!-- 2FA Input --> <!-- 2FA Input -->
<div class="space-y-4 animate-slide-up"> <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> <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> <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="relative">
@@ -214,7 +245,6 @@
maxlength="6" maxlength="6"
required required
disabled={isLoading} disabled={isLoading}
autofocus
/> />
</div> </div>
</div> </div>

View File

@@ -430,7 +430,11 @@
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-md font-medium transition-colors" disabled={isUpdatingProfile}> <button
type="submit"
class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-md font-medium transition-colors"
disabled={isUpdatingProfile}
>
{#if isUpdatingProfile} {#if isUpdatingProfile}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" /> <div class="spinner w-4 h-4" />
@@ -461,8 +465,9 @@
<h3 class="font-medium text-gray-900 mb-4">Información General</h3> <h3 class="font-medium text-gray-900 mb-4">Información General</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label class="form-label">Razón Social</label> <label class="form-label" for="business_name">Razón Social</label>
<input <input
id="business_name"
type="text" type="text"
class="form-input" class="form-input"
bind:value={businessProfile.business_name} bind:value={businessProfile.business_name}
@@ -471,8 +476,9 @@
</div> </div>
<div> <div>
<label class="form-label">Nombre Comercial</label> <label class="form-label" for="commercial_name">Nombre Comercial</label>
<input <input
id="commercial_name"
type="text" type="text"
class="form-input" class="form-input"
bind:value={businessProfile.commercial_name} bind:value={businessProfile.commercial_name}
@@ -481,8 +487,9 @@
</div> </div>
<div> <div>
<label class="form-label">Clave de Cliente</label> <label class="form-label" for="client_code">Clave de Cliente</label>
<input <input
id="client_code"
type="text" type="text"
class="form-input" class="form-input"
bind:value={businessProfile.client_code} bind:value={businessProfile.client_code}
@@ -491,7 +498,7 @@
</div> </div>
<div> <div>
<label class="form-label">Tipo de Cliente</label> <label class="form-label" for="client_type">Tipo de Cliente</label>
<select class="form-input" bind:value={businessProfile.client_type}> <select class="form-input" bind:value={businessProfile.client_type}>
<option value="">Seleccionar...</option> <option value="">Seleccionar...</option>
<option value="corporativo">Corporativo</option> <option value="corporativo">Corporativo</option>
@@ -503,8 +510,9 @@
</div> </div>
<div> <div>
<label class="form-label">RFC</label> <label class="form-label" for="rfc">RFC</label>
<input <input
id="rfc"
type="text" type="text"
class="form-input {businessProfileErrors.rfc ? 'border-red-300' : ''}" class="form-input {businessProfileErrors.rfc ? 'border-red-300' : ''}"
bind:value={businessProfile.rfc} bind:value={businessProfile.rfc}
@@ -518,8 +526,9 @@
</div> </div>
<div> <div>
<label class="form-label">ID Fiscal (Otros países)</label> <label class="form-label" for="tax_id">ID Fiscal (Otros países)</label>
<input <input
id="tax_id"
type="text" type="text"
class="form-input" class="form-input"
bind:value={businessProfile.tax_id} bind:value={businessProfile.tax_id}
@@ -619,8 +628,11 @@
<h3 class="font-medium text-gray-900 mb-4">Representantes</h3> <h3 class="font-medium text-gray-900 mb-4">Representantes</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label class="form-label">Encargado/Representante</label> <label class="form-label" for="company_representative"
>Encargado/Representante</label
>
<input <input
id="company_representative"
type="text" type="text"
class="form-input" class="form-input"
bind:value={businessProfile.company_representative} bind:value={businessProfile.company_representative}
@@ -629,8 +641,9 @@
</div> </div>
<div> <div>
<label class="form-label">Representante Legal</label> <label class="form-label" for="legal_representative">Representante Legal</label>
<input <input
id="legal_representative"
type="text" type="text"
class="form-input" class="form-input"
bind:value={businessProfile.legal_representative} bind:value={businessProfile.legal_representative}
@@ -645,7 +658,7 @@
<h3 class="font-medium text-gray-900 mb-4">Configuración</h3> <h3 class="font-medium text-gray-900 mb-4">Configuración</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label class="form-label">Moneda Preferida</label> <label class="form-label" for="preferred_currency">Moneda Preferida</label>
<select class="form-input" bind:value={businessProfile.preferred_currency}> <select class="form-input" bind:value={businessProfile.preferred_currency}>
<option value="MXN">MXN - Peso Mexicano</option> <option value="MXN">MXN - Peso Mexicano</option>
<option value="USD">USD - Dólar Americano</option> <option value="USD">USD - Dólar Americano</option>
@@ -654,12 +667,12 @@
</div> </div>
<div> <div>
<label class="form-label">Nacionalidad</label> <label class="form-label" for="nationality">Nacionalidad</label>
<input type="text" class="form-input" bind:value={businessProfile.nationality} /> <input type="text" class="form-input" bind:value={businessProfile.nationality} />
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="form-label">Notas Adicionales</label> <label class="form-label" for="notes">Notas Adicionales</label>
<textarea <textarea
class="form-input" class="form-input"
rows="3" rows="3"
@@ -972,7 +985,11 @@
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-md font-medium transition-colors" disabled={isChangingPassword}> <button
type="submit"
class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-md font-medium transition-colors"
disabled={isChangingPassword}
>
{#if isChangingPassword} {#if isChangingPassword}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" /> <div class="spinner w-4 h-4" />

View File

@@ -24,7 +24,7 @@
// No mostrar error en polling silencioso // No mostrar error en polling silencioso
console.error('Error recargando comentarios:', error); console.error('Error recargando comentarios:', error);
} }
} }
onMount(() => { onMount(() => {
// Redirect if not authenticated // Redirect if not authenticated
@@ -49,7 +49,7 @@
clearInterval(pollingInterval); clearInterval(pollingInterval);
} }
}; };
}); });
// Format date // Format date
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
@@ -109,8 +109,13 @@
// Validate file type // Validate file type
const allowedTypes = [ const allowedTypes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/jpeg',
'application/pdf', 'text/plain', 'application/msword', 'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@@ -134,6 +139,16 @@
} }
} }
async function handleDownloadAttachment(attachment: any) {
try {
await tickets.downloadAttachment(ticketId, attachment.id, attachment.original_filename);
toast.success('Descarga iniciada');
} catch (error: any) {
console.error('Download error:', error);
toast.error(error.message || 'Error al descargar el archivo');
}
}
function handleCloseTicket() { function handleCloseTicket() {
showCloseDialog = true; showCloseDialog = true;
} }
@@ -158,7 +173,8 @@
} }
// Check if user can close ticket // Check if user can close ticket
$: canClose = $tickets.currentTicket && $: canClose =
$tickets.currentTicket &&
['RESOLVED', 'WAITING_FOR_CLIENT'].includes($tickets.currentTicket.status); ['RESOLVED', 'WAITING_FOR_CLIENT'].includes($tickets.currentTicket.status);
</script> </script>
@@ -171,22 +187,24 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{#if $tickets.isLoading} {#if $tickets.isLoading}
<div class="text-center py-12"> <div class="text-center py-12">
<div class="spinner w-8 h-8 mx-auto mb-4"></div> <div class="spinner w-8 h-8 mx-auto mb-4" />
<p class="text-gray-600">Cargando ticket...</p> <p class="text-gray-600">Cargando ticket...</p>
</div> </div>
{:else if $tickets.error} {:else if $tickets.error}
<div class="text-center py-12"> <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"> <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"> <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" /> <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> </svg>
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar ticket</h3> <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> <p class="text-gray-600 mb-4">{$tickets.error}</p>
<button <button on:click={() => tickets.loadTicket(ticketId)} class="btn-primary px-4 py-2">
on:click={() => tickets.loadTicket(ticketId)}
class="btn-primary px-4 py-2"
>
Reintentar Reintentar
</button> </button>
</div> </div>
@@ -261,14 +279,18 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2"> <h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<span>Archivos Adjuntos</span> <span>Archivos Adjuntos</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> <span
{$tickets.attachments.length} archivo{$tickets.attachments.length !== 1 ? 's' : ''} class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{$tickets.attachments.length} archivo{$tickets.attachments.length !== 1
? 's'
: ''}
</span> </span>
</h3> </h3>
<button <button
class="text-sm text-gray-500 hover:text-gray-700" class="text-sm text-gray-500 hover:text-gray-700"
title="Ver/Ocultar archivos adjuntos" title="Ver/Ocultar archivos adjuntos"
on:click={() => showAttachments = !showAttachments} on:click={() => (showAttachments = !showAttachments)}
> >
{showAttachments ? 'Ocultar' : 'Ver'} archivos {showAttachments ? 'Ocultar' : 'Ver'} archivos
</button> </button>
@@ -278,11 +300,23 @@
<div class="card-content"> <div class="card-content">
<div class="space-y-3"> <div class="space-y-3">
{#each $tickets.attachments as attachment} {#each $tickets.attachments as attachment}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"> <div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center"> <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"> <svg
<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" /> 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> </svg>
</div> </div>
<div> <div>
@@ -290,22 +324,26 @@
{attachment.original_filename} {attachment.original_filename}
</p> </p>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB • {Math.round(attachment.size_bytes / 1024)} KB • Subido por {attachment.uploaded_by_name}
Subido por {attachment.uploaded_by_name}
{formatDate(attachment.uploaded_at)} {formatDate(attachment.uploaded_at)}
</p> </p>
</div> </div>
</div> </div>
<a <button
href="/api/v1/tickets/{ticketId}/attachments/{attachment.id}/download" on:click={() => handleDownloadAttachment(attachment)}
class="btn-ghost p-2 hover:bg-blue-100 rounded-md transition-colors" class="btn-ghost p-2 hover:bg-blue-100 rounded-md transition-colors"
target="_blank"
title="Descargar archivo" title="Descargar archivo"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
</a> </button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -329,9 +367,16 @@
{#each $tickets.comments as comment} {#each $tickets.comments as comment}
{console.log('Comment:', comment)} {console.log('Comment:', comment)}
<div class="flex space-x-3"> <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"> <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"> <span class="text-primary-600 text-xs font-medium">
{comment.author_name ? comment.author_name.split(' ').map(n => n[0]).join('') : '??'} {comment.author_name
? comment.author_name
.split(' ')
.map(n => n[0])
.join('')
: '??'}
</span> </span>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -366,7 +411,7 @@
placeholder="Escribe tu comentario o respuesta..." placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment} bind:value={newComment}
disabled={isSubmittingComment} disabled={isSubmittingComment}
></textarea> />
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
@@ -385,10 +430,15 @@
disabled={isUploading} disabled={isUploading}
> >
{#if isUploading} {#if isUploading}
<div class="spinner w-4 h-4"></div> <div class="spinner w-4 h-4" />
{:else} {:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
{/if} {/if}
<span class="text-sm">Adjuntar archivo</span> <span class="text-sm">Adjuntar archivo</span>
@@ -402,7 +452,7 @@
> >
{#if isSubmittingComment} {#if isSubmittingComment}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div> <div class="spinner w-4 h-4" />
<span>Enviando...</span> <span>Enviando...</span>
</div> </div>
{:else} {:else}
@@ -426,12 +476,16 @@
<div class="card-content space-y-4"> <div class="card-content space-y-4">
<div> <div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt> <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> <dd class="text-sm text-gray-900 font-mono">
#{$tickets.currentTicket.id.substring(0, 8)}
</dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Categoría</dt> <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> <dd class="text-sm text-gray-900">
{$tickets.currentTicket.category_name || 'Sin categoría'}
</dd>
</div> </div>
{#if $tickets.currentTicket.assigned_to_name} {#if $tickets.currentTicket.assigned_to_name}
@@ -454,7 +508,12 @@
{#if $tickets.currentTicket.due_date} {#if $tickets.currentTicket.due_date}
<div> <div>
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt> <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' : ''}"> <dd
class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) <
new Date()
? 'text-red-600'
: ''}"
>
{formatDate($tickets.currentTicket.due_date)} {formatDate($tickets.currentTicket.due_date)}
{#if new Date($tickets.currentTicket.due_date) < new Date()} {#if new Date($tickets.currentTicket.due_date) < new Date()}
<span class="block text-xs text-red-500">¡Vencido!</span> <span class="block text-xs text-red-500">¡Vencido!</span>
@@ -472,26 +531,47 @@
<!-- Close Ticket Dialog --> <!-- Close Ticket Dialog -->
{#if showCloseDialog} {#if showCloseDialog}
<div class="fixed inset-0 z-50 overflow-y-auto"> <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
<div class="fixed inset-0 transition-opacity" on:click={cancelCloseTicket}> class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
<div class="absolute inset-0 bg-gray-500 opacity-75"></div> >
<div
class="fixed inset-0 transition-opacity"
role="dialog"
tabindex="0"
on:click={cancelCloseTicket}
on:keydown={e => e.key === 'Escape' && 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="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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start"> <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"> <div
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 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"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> >
<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> </svg>
</div> </div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <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"> <h3 class="text-lg leading-6 font-medium text-gray-900">Cerrar Ticket</h3>
Cerrar Ticket
</h3>
<div class="mt-2"> <div class="mt-2">
<p class="text-sm text-gray-500"> <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. ¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el
problema ha sido resuelto satisfactoriamente.
</p> </p>
</div> </div>
@@ -506,7 +586,7 @@
placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..." placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..."
bind:value={closeResolution} bind:value={closeResolution}
disabled={isClosingTicket} disabled={isClosingTicket}
></textarea> />
</div> </div>
</div> </div>
</div> </div>
@@ -520,7 +600,7 @@
> >
{#if isClosingTicket} {#if isClosingTicket}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div> <div class="spinner w-4 h-4" />
<span>Cerrando...</span> <span>Cerrando...</span>
</div> </div>
{:else} {:else}

View File

@@ -18,7 +18,8 @@
"DOM.Iterable" "DOM.Iterable"
], ],
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"isolatedModules": true "isolatedModules": true,
"verbatimModuleSyntax": true
}, },
"include": [ "include": [
"src/**/*", "src/**/*",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@servicemanager/internal-frontend", "name": "@servicemanager/internal-frontend",
"version": "0.1.0", "version": "1.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -23,7 +23,12 @@
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" 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"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg> </svg>
</button> </button>
@@ -46,15 +51,23 @@
</span> </span>
</div> </div>
<span class="hidden sm:block text-sm"> <span class="hidden sm:block text-sm">
{$auth.user?.first_name} {$auth.user?.last_name} {$auth.user?.first_name}
{$auth.user?.last_name}
</span> </span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</button> </button>
{#if isMenuOpen} {#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="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="py-1">
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200"> <div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
{$auth.user?.email} {$auth.user?.email}
@@ -62,7 +75,7 @@
<a <a
href="/profile" href="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => isMenuOpen = false} on:click={() => (isMenuOpen = false)}
> >
Mi Perfil Mi Perfil
</a> </a>
@@ -84,6 +97,9 @@
{#if isMenuOpen} {#if isMenuOpen}
<div <div
class="fixed inset-0 z-40 lg:hidden" class="fixed inset-0 z-40 lg:hidden"
on:click={() => isMenuOpen = false} role="dialog"
></div> tabindex="0"
on:click={() => (isMenuOpen = false)}
on:keydown={e => e.key === 'Escape' && (isMenuOpen = false)}
/>
{/if} {/if}

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { auth } from '$lib/stores/auth.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { auth } from '$lib/stores/auth.js';
export let open = false; export let open = false;
@@ -70,27 +70,46 @@
} }
function isCurrentPage(href: string) { function isCurrentPage(href: string) {
return $page.url.pathname === href || return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
($page.url.pathname.startsWith(href) && href !== '/');
} }
</script> </script>
<!-- Mobile sidebar backdrop --> <!-- Mobile sidebar backdrop -->
{#if open} {#if open}
<div class="fixed inset-0 z-40 lg:hidden"> <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
class="fixed inset-0 bg-gray-600 bg-opacity-75"
role="dialog"
tabindex="0"
on:click={() => (open = false)}
on:keydown={e => e.key === 'Escape' && (open = false)}
/>
</div> </div>
{/if} {/if}
<!-- Sidebar --> <!-- 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="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"> <div class="flex flex-col h-full">
<!-- Logo --> <!-- Logo -->
<div class="flex items-center justify-between h-16 px-6 bg-primary-600"> <div class="flex items-center justify-between h-16 px-6 bg-primary-600">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center"> <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"> <svg
<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" /> 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> </svg>
</div> </div>
<div class="text-white"> <div class="text-white">
@@ -100,11 +119,16 @@
</div> </div>
<button <button
on:click={() => open = false} on:click={() => (open = false)}
class="p-2 rounded-md text-primary-100 hover:text-white hover:bg-primary-500 lg:hidden" 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"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
@@ -114,12 +138,12 @@
{#each navigation as item} {#each navigation as item}
<a <a
href={item.href} href={item.href}
class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors { class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors {isCurrentPage(
isCurrentPage(item.href) item.href
)
? 'bg-primary-100 text-primary-700' ? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'}"
}" on:click={() => (open = false)}
on:click={() => open = false}
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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} /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
@@ -139,12 +163,17 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate"> <p class="text-sm font-medium text-gray-900 truncate">
{$auth.user?.first_name} {$auth.user?.last_name} {$auth.user?.first_name}
{$auth.user?.last_name}
</p> </p>
<p class="text-xs text-gray-500 truncate"> <p class="text-xs text-gray-500 truncate">
{$auth.user?.role === 'ADMIN' ? 'Administrador' : {$auth.user?.role === 'ADMIN'
$auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' : ? 'Administrador'
$auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'} : $auth.user?.role === 'SUPPORT_MANAGER'
? 'Gerente de Soporte'
: $auth.user?.role === 'AGENT'
? 'Agente'
: 'Auditor'}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -25,11 +25,15 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const authState = get(auth); const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
const user = authState.user || (typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('internal_auth_user') || 'null') : null);
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
if (token) { if (token) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
if (user && user.tenant_id) {
headers.set('X-Tenant-ID', user.tenant_id);
}
if (!headers.has('Content-Type')) { if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
} }
@@ -62,6 +66,50 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
return response.json(); return response.json();
} }
async function downloadFile(endpoint: string, filename: string): Promise<void> {
const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
const user = authState.user || (typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('internal_auth_user') || 'null') : null);
const headers = new Headers();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
if (user && user.tenant_id) {
headers.set('X-Tenant-ID', user.tenant_id);
}
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET',
headers
});
if (response.status === 401) {
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 || `Download error: ${response.statusText}`);
}
// Crear blob y descargar
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
export const api = { export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) => get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }), request<T>(endpoint, { method: 'GET', params }),
@@ -76,5 +124,8 @@ export const api = {
request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) }), request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) }),
delete: <T>(endpoint: string) => delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }) request<T>(endpoint, { method: 'DELETE' }),
downloadFile: (endpoint: string, filename: string) =>
downloadFile(endpoint, filename)
}; };

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/utils/api'; import { page } from '$app/stores';
import { toast } from '$lib/stores/toast'; import { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api';
import { onMount } from 'svelte';
let ticketId: string; let ticketId: string;
let ticket = null; let ticket = null;
let comments = []; let comments = [];
let attachments = [];
let users = []; let users = [];
let isLoading = false; let isLoading = false;
let newComment = ''; let newComment = '';
@@ -38,18 +39,20 @@
// No mostrar error en polling silencioso // No mostrar error en polling silencioso
console.error('Error recargando comentarios:', e); console.error('Error recargando comentarios:', e);
} }
} }
async function loadData() { async function loadData() {
isLoading = true; isLoading = true;
try { try {
const [ticketData, commentsData, usersData] = await Promise.all([ const [ticketData, commentsData, attachmentsData, usersData] = await Promise.all([
api.get(`/tickets/${ticketId}`), api.get(`/tickets/${ticketId}`),
api.get(`/tickets/${ticketId}/comments`), api.get(`/tickets/${ticketId}/comments`),
api.get(`/tickets/${ticketId}/attachments`),
api.get('/users/') api.get('/users/')
]); ]);
ticket = ticketData; ticket = ticketData;
comments = commentsData; comments = commentsData;
attachments = attachmentsData;
users = usersData; users = usersData;
} catch (e) { } catch (e) {
toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido')); toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido'));
@@ -105,6 +108,19 @@
}); });
} }
async function handleDownloadAttachment(attachment: any) {
try {
await api.downloadFile(
`/tickets/${ticketId}/attachments/${attachment.id}/download`,
attachment.original_filename
);
toast.success('Descarga iniciada');
} catch (error) {
console.error('Download error:', error);
toast.error('Error al descargar el archivo');
}
}
onMount(() => { onMount(() => {
ticketId = $page.params.id; ticketId = $page.params.id;
if (ticketId) { if (ticketId) {
@@ -121,14 +137,13 @@
clearInterval(pollingInterval); clearInterval(pollingInterval);
} }
}; };
}); });
</script> </script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
{#if isLoading} {#if isLoading}
<div class="text-center py-12"> <div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div> <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
<p class="mt-2 text-gray-600">Cargando ticket...</p> <p class="mt-2 text-gray-600">Cargando ticket...</p>
</div> </div>
{:else if ticket} {:else if ticket}
@@ -153,10 +168,18 @@
{ticket.subject || ticket.title} {ticket.subject || ticket.title}
</h1> </h1>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 bg-{getStatusBadge(ticket.status).color}-100 text-{getStatusBadge(ticket.status).color}-800"> <span
class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 bg-{getStatusBadge(
ticket.status
).color}-100 text-{getStatusBadge(ticket.status).color}-800"
>
{getStatusBadge(ticket.status).label} {getStatusBadge(ticket.status).label}
</span> </span>
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 bg-{getPriorityBadge(ticket.priority).color}-100 text-{getPriorityBadge(ticket.priority).color}-800"> <span
class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 bg-{getPriorityBadge(
ticket.priority
).color}-100 text-{getPriorityBadge(ticket.priority).color}-800"
>
{getPriorityBadge(ticket.priority).label} {getPriorityBadge(ticket.priority).label}
</span> </span>
<span class="text-sm text-gray-500"> <span class="text-sm text-gray-500">
@@ -183,6 +206,74 @@
</div> </div>
</div> </div>
<!-- Attachments Section -->
{#if attachments && attachments.length > 0}
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">
Archivos Adjuntos ({attachments.length})
</h3>
</div>
<div class="px-6 py-5">
<div class="space-y-2">
{#each attachments as attachment}
<div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-3">
<div
class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0"
>
<svg
class="w-5 h-5 text-indigo-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 class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{attachment.original_filename}
</p>
<p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB
{#if attachment.uploaded_by_name}
• Subido por {attachment.uploaded_by_name}
{/if}
{#if attachment.uploaded_at}
{formatDate(attachment.uploaded_at)}
{/if}
</p>
</div>
</div>
<button
on:click={() => handleDownloadAttachment(attachment)}
class="inline-flex items-center p-2 text-sm font-medium text-indigo-600 hover:bg-indigo-50 rounded-md transition-colors"
title="Descargar {attachment.original_filename}"
>
<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="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>
</button>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Comments Section --> <!-- Comments Section -->
<div class="bg-white shadow rounded-lg"> <div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200"> <div class="px-6 py-5 border-b border-gray-200">
@@ -197,9 +288,16 @@
<div class="space-y-4"> <div class="space-y-4">
{#each comments as comment} {#each comments as comment}
<div class="flex space-x-3"> <div class="flex space-x-3">
<div class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0"> <div
class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<span class="text-indigo-600 text-xs font-medium"> <span class="text-indigo-600 text-xs font-medium">
{comment.author_name ? comment.author_name.split(' ').map(n => n[0]).join('') : '??'} {comment.author_name
? comment.author_name
.split(' ')
.map(n => n[0])
.join('')
: '??'}
</span> </span>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -234,7 +332,7 @@
placeholder="Escribe tu comentario o respuesta..." placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment} bind:value={newComment}
disabled={isSubmittingComment} disabled={isSubmittingComment}
></textarea> />
<div class="flex justify-end"> <div class="flex justify-end">
<button <button
@@ -244,7 +342,9 @@
> >
{#if isSubmittingComment} {#if isSubmittingComment}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> <div
class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"
/>
<span>Enviando...</span> <span>Enviando...</span>
</div> </div>
{:else} {:else}
@@ -268,7 +368,9 @@
<div class="px-6 py-5 space-y-4"> <div class="px-6 py-5 space-y-4">
<div> <div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt> <dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
<dd class="text-sm text-gray-900 font-mono">#{ticket.ticket_number || ticket.id.substring(0, 8)}</dd> <dd class="text-sm text-gray-900 font-mono">
#{ticket.ticket_number || ticket.id.substring(0, 8)}
</dd>
</div> </div>
<div> <div>

View File

@@ -18,7 +18,8 @@
"DOM.Iterable" "DOM.Iterable"
], ],
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"isolatedModules": true "isolatedModules": true,
"verbatimModuleSyntax": true
}, },
"include": [ "include": [
"src/**/*", "src/**/*",

262
scripts/db_utils.py Normal file
View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""
Database Utilities Script
Herramientas administrativas para gestión de base de datos
Uso:
python scripts/db_utils.py list-users [--tenant-id UUID]
python scripts/db_utils.py check-user EMAIL
python scripts/db_utils.py reset-password EMAIL [--password PASSWORD]
python scripts/db_utils.py list-tickets [--tenant-id UUID] [--limit N]
python scripts/db_utils.py check-ticket TICKET_ID
Ejemplos:
python scripts/db_utils.py list-users
python scripts/db_utils.py check-user admin@example.com
python scripts/db_utils.py reset-password admin@example.com --password admin123
python scripts/db_utils.py list-tickets --limit 10
"""
import asyncio
import sys
import os
from typing import Optional
import argparse
from pathlib import Path
# Agregar backend al path para imports
backend_path = Path(__file__).parent.parent / "backend"
sys.path.insert(0, str(backend_path))
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.models.user import User
from app.models.ticket import Ticket
from app.models.tenant import Tenant
from app.core.security import SecurityUtils
class DBUtils:
"""Utilidades de gestión de base de datos"""
def __init__(self, database_url: Optional[str] = None):
self.database_url = database_url or os.getenv(
'DATABASE_URL',
'postgresql+asyncpg://postgres:postgres@localhost:5432/servicemanager'
)
self.engine = create_async_engine(self.database_url, echo=False)
self.async_session = sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False
)
async def list_users(self, tenant_id: Optional[str] = None):
"""Listar todos los usuarios"""
async with self.async_session() as session:
query = select(User)
if tenant_id:
query = query.where(User.tenant_id == tenant_id)
result = await session.execute(query)
users = result.scalars().all()
if not users:
print("❌ No se encontraron usuarios")
return
print(f"\n{'='*80}")
print(f"📋 USUARIOS ({len(users)} encontrados)")
print(f"{'='*80}\n")
for user in users:
print(f" Email: {user.email}")
print(f" Role: {user.role}")
print(f" ID: {user.id}")
print(f" Tenant ID: {user.tenant_id}")
print(f" Activo: {'' if user.is_active else ''}")
print(f" {'-'*76}")
async def check_user(self, email: str):
"""Verificar información de un usuario específico"""
async with self.async_session() as session:
result = await session.execute(
select(User).where(User.email == email)
)
user = result.scalar_one_or_none()
if not user:
print(f"❌ Usuario '{email}' no encontrado")
return
print(f"\n{'='*80}")
print(f"👤 INFORMACIÓN DEL USUARIO")
print(f"{'='*80}\n")
print(f" Email: {user.email}")
print(f" Nombre: {user.first_name} {user.last_name}")
print(f" Role: {user.role}")
print(f" ID: {user.id}")
print(f" Tenant ID: {user.tenant_id}")
print(f" Activo: {'' if user.is_active else ''}")
print(f" 2FA: {'✅ Habilitado' if user.totp_secret else '❌ Deshabilitado'}")
print(f" Creado: {user.created_at}")
print(f"\n{'='*80}")
async def reset_password(self, email: str, new_password: str = "admin123"):
"""Resetear contraseña de un usuario"""
async with self.async_session() as session:
result = await session.execute(
select(User).where(User.email == email)
)
user = result.scalar_one_or_none()
if not user:
print(f"❌ Usuario '{email}' no encontrado")
return
# Hash nueva contraseña
password_hash = SecurityUtils.hash_password(new_password)
user.password_hash = password_hash
try:
await session.commit()
print(f"\n✅ Contraseña actualizada exitosamente")
print(f" Usuario: {email}")
print(f" Nueva contraseña: {new_password}")
print(f"\n⚠️ IMPORTANTE: Cambia esta contraseña después del primer login")
except Exception as e:
await session.rollback()
print(f"❌ Error al actualizar contraseña: {e}")
async def list_tickets(self, tenant_id: Optional[str] = None, limit: int = 20):
"""Listar tickets"""
async with self.async_session() as session:
query = select(Ticket).order_by(Ticket.created_at.desc()).limit(limit)
if tenant_id:
query = query.where(Ticket.tenant_id == tenant_id)
result = await session.execute(query)
tickets = result.scalars().all()
if not tickets:
print("❌ No se encontraron tickets")
return
print(f"\n{'='*80}")
print(f"🎫 TICKETS ({len(tickets)} encontrados, límite: {limit})")
print(f"{'='*80}\n")
for ticket in tickets:
print(f" {ticket.ticket_number} | {ticket.status} | {ticket.priority}")
print(f" Asunto: {ticket.subject}")
print(f" ID: {ticket.id}")
print(f" Tenant: {ticket.tenant_id}")
print(f" Creado: {ticket.created_at}")
print(f" {'-'*76}")
async def check_ticket(self, ticket_id: str):
"""Verificar información de un ticket específico"""
async with self.async_session() as session:
result = await session.execute(
select(Ticket).where(Ticket.id == ticket_id)
)
ticket = result.scalar_one_or_none()
if not ticket:
print(f"❌ Ticket '{ticket_id}' no encontrado")
return
# Obtener creador
creator_result = await session.execute(
select(User).where(User.id == ticket.created_by)
)
creator = creator_result.scalar_one_or_none()
# Obtener asignado
assigned = None
if ticket.assigned_to:
assigned_result = await session.execute(
select(User).where(User.id == ticket.assigned_to)
)
assigned = assigned_result.scalar_one_or_none()
print(f"\n{'='*80}")
print(f"🎫 INFORMACIÓN DEL TICKET")
print(f"{'='*80}\n")
print(f" Número: {ticket.ticket_number}")
print(f" Asunto: {ticket.subject}")
print(f" Estado: {ticket.status}")
print(f" Prioridad: {ticket.priority}")
print(f" ID: {ticket.id}")
print(f" Tenant ID: {ticket.tenant_id}")
if creator:
print(f" Creado por: {creator.email} ({creator.role})")
if assigned:
print(f" Asignado a: {assigned.email} ({assigned.role})")
print(f" Creado: {ticket.created_at}")
print(f" Actualizado: {ticket.updated_at}")
print(f"\n{'='*80}")
async def close(self):
"""Cerrar conexión"""
await self.engine.dispose()
async def main():
parser = argparse.ArgumentParser(
description='Utilidades de gestión de base de datos',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
subparsers = parser.add_subparsers(dest='command', help='Comando a ejecutar')
# list-users
list_users_parser = subparsers.add_parser('list-users', help='Listar usuarios')
list_users_parser.add_argument('--tenant-id', help='Filtrar por tenant ID')
# check-user
check_user_parser = subparsers.add_parser('check-user', help='Verificar usuario')
check_user_parser.add_argument('email', help='Email del usuario')
# reset-password
reset_password_parser = subparsers.add_parser('reset-password', help='Resetear contraseña')
reset_password_parser.add_argument('email', help='Email del usuario')
reset_password_parser.add_argument('--password', default='admin123', help='Nueva contraseña')
# list-tickets
list_tickets_parser = subparsers.add_parser('list-tickets', help='Listar tickets')
list_tickets_parser.add_argument('--tenant-id', help='Filtrar por tenant ID')
list_tickets_parser.add_argument('--limit', type=int, default=20, help='Límite de resultados')
# check-ticket
check_ticket_parser = subparsers.add_parser('check-ticket', help='Verificar ticket')
check_ticket_parser.add_argument('ticket_id', help='ID del ticket')
args = parser.parse_args()
if not args.command:
parser.print_help()
return
utils = DBUtils()
try:
if args.command == 'list-users':
await utils.list_users(args.tenant_id)
elif args.command == 'check-user':
await utils.check_user(args.email)
elif args.command == 'reset-password':
await utils.reset_password(args.email, args.password)
elif args.command == 'list-tickets':
await utils.list_tickets(args.tenant_id, args.limit)
elif args.command == 'check-ticket':
await utils.check_ticket(args.ticket_id)
finally:
await utils.close()
if __name__ == "__main__":
asyncio.run(main())

Binary file not shown.

View File

@@ -1,34 +0,0 @@
# Test de Attachments API
# Ejecutar desde PowerShell
# 1. Login
$body = '{"email":"admin@aduanasoft.com","password":"admin123","tenant_slug":"aduanasoft"}'
$login = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" -Method POST -Body $body -ContentType "application/json"
$token = $login.data.access_token
$tenantId = $login.data.user.tenant_id
$headers = @{
"Authorization" = "Bearer $token"
"X-Tenant-ID" = $tenantId
}
Write-Host "Autenticacion exitosa" -ForegroundColor Green
# 2. Listar tickets
$tickets = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets" -Headers $headers
$ticketId = $tickets.data[0].id
Write-Host "Ticket ID obtenido: $ticketId" -ForegroundColor Green
# 3. Listar attachments del ticket
$attachments = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets/$ticketId/attachments" -Headers $headers
Write-Host "Attachments listados: $($attachments.Count) encontrados" -ForegroundColor Green
if ($attachments.Count -gt 0) {
$attachments | Format-Table id, original_filename, file_size, created_at
}
Write-Host ""
Write-Host "TEST COMPLETADO" -ForegroundColor Cyan

View File

@@ -1,45 +0,0 @@
# Test script para attachments endpoint
Write-Host "=== Testing Attachments Endpoint ===" -ForegroundColor Cyan
# 1. Login
$body = '{"email":"admin@aduanasoft.com","password":"admin123","tenant_slug":"aduanasoft"}'
try {
$login = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" -Method POST -Body $body -ContentType "application/json"
Write-Host "[OK] Login exitoso" -ForegroundColor Green
$token = $login.access_token
$tenantId = $login.user.tenant_id
Write-Host "Token: $($token.Substring(0,20))..." -ForegroundColor Gray
Write-Host "Tenant ID: $tenantId" -ForegroundColor Gray
# 2. Test attachments endpoint
$headers = @{
"Authorization" = "Bearer $token"
"X-Tenant-ID" = $tenantId
}
$url = "http://localhost:8000/v1/tickets/68eaf0fe-b5c3-4d42-9b36-895b439be583/attachments"
Write-Host "`nProbando GET $url" -ForegroundColor Yellow
try {
$response = Invoke-WebRequest -Uri $url -Headers $headers -Method GET
Write-Host "[OK] Status Code: $($response.StatusCode)" -ForegroundColor Green
$data = $response.Content | ConvertFrom-Json
Write-Host "[OK] Attachments encontrados: $($data.Count)" -ForegroundColor Green
if ($data.Count -gt 0) {
$data | Format-Table id, original_filename, file_size
} else {
Write-Host " (No hay attachments para este ticket)" -ForegroundColor Gray
}
} catch {
Write-Host "[ERROR] En request: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Status Code: $($_.Exception.Response.StatusCode.value__)" -ForegroundColor Red
}
} catch {
Write-Host "[ERROR] En login: $($_.Exception.Message)" -ForegroundColor Red
}