Compare commits

..

5 Commits

Author SHA1 Message Date
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
27 changed files with 1488 additions and 598 deletions

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

BIN
backend/.coverage Normal file

Binary file not shown.

View File

@@ -82,17 +82,25 @@ async def read_categories(
async def create_category(
category: CategoryCreate,
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.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear categorías.
✅ 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(
**category.model_dump(),
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
tenant_id=current_user.tenant_id
)
db.add(db_category)
@@ -138,8 +146,16 @@ async def update_category(
"""
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.
"""
# 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(
Category.id == category_id,
Category.tenant_id == current_user.tenant_id
@@ -172,8 +188,16 @@ async def delete_category(
"""
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.
"""
# 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(
Category.id == category_id,
Category.tenant_id == current_user.tenant_id

View File

@@ -70,17 +70,25 @@ async def read_systems(
async def create_system(
system: SystemCreate,
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.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear sistemas.
✅ 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(
**system.model_dump(),
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
tenant_id=current_user.tenant_id
)
db.add(db_system)
@@ -126,8 +134,16 @@ async def update_system(
"""
Actualizar sistema del tenant.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar sistemas.
✅ 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(
System.id == system_id,
System.tenant_id == current_user.tenant_id
@@ -160,8 +176,16 @@ async def delete_system(
"""
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.
"""
# 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(
System.id == system_id,
System.tenant_id == current_user.tenant_id

View File

@@ -78,28 +78,44 @@ async def create_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:
# Generar número de ticket único
# Generar número de ticket único basado en el máximo existente
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
ticket_number = f"TK-{count + 1:06d}"
last_ticket_number = result.scalar_one_or_none()
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
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:
category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory
category = await db.get(Category, category_uuid)
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
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:
system = await db.get(System, system_uuid)
if not system:
@@ -115,7 +131,7 @@ async def create_ticket(
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo
affected_system_id=system_uuid,
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
status=TicketStatus.NEW,
@@ -127,7 +143,7 @@ async def create_ticket(
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id en respuesta
# ✅ Éxito - retornar ticket creado
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
@@ -137,7 +153,7 @@ async def create_ticket(
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"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),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
@@ -150,13 +166,31 @@ async def create_ticket(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}"
)
except HTTPException:
# Re-lanzar HTTPExceptions directamente
await db.rollback()
raise
except Exception as e:
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(
status_code=status.HTTP_400_BAD_REQUEST,
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])
async def get_tickets(
@@ -167,13 +201,19 @@ async def get_tickets(
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(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
Ticket.tenant_id == current_user.tenant_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:
try:
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)
async def get_ticket(
ticket_id: str,
@@ -417,6 +414,8 @@ async def get_ticket(
):
"""
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:
ticket_uuid = uuid.UUID(ticket_id)
@@ -426,12 +425,16 @@ async def get_ticket(
detail="Invalid ticket ID format"
)
# Construir query basado en el rol del usuario
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
Ticket.tenant_id == current_user.tenant_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)
ticket = result.scalars().first()
@@ -468,6 +471,8 @@ async def update_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:
ticket_uuid = uuid.UUID(ticket_id)
@@ -477,12 +482,16 @@ async def update_ticket(
detail="Invalid ticket ID format"
)
# Construir query basado en el rol del usuario
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
Ticket.tenant_id == current_user.tenant_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)
db_ticket = result.scalars().first()
@@ -544,6 +553,8 @@ async def close_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:
ticket_uuid = uuid.UUID(ticket_id)
@@ -553,12 +564,16 @@ async def close_ticket(
detail="Invalid ticket ID format"
)
# Construir query basado en el rol del usuario
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
Ticket.tenant_id == current_user.tenant_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)
db_ticket = result.scalars().first()
@@ -908,10 +923,16 @@ async def download_attachment(
current_tenant: Tenant = Depends(get_current_tenant)
):
"""Descargar un archivo adjunto"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
try:
ticket_uuid = uuid.UUID(ticket_id)
attachment_uuid = uuid.UUID(attachment_id)
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")
# Verificar ticket
@@ -921,6 +942,7 @@ async def download_attachment(
ticket = result.scalar_one_or_none()
if not ticket:
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
raise HTTPException(status_code=404, detail="Ticket no encontrado")
# Obtener attachment
@@ -931,12 +953,26 @@ async def download_attachment(
attachment = result.scalar_one_or_none()
if not attachment:
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
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
try:
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
logger.info(f"Returning file: {attachment.original_filename}")
return FileResponse(
path=file_path,
filename=attachment.original_filename,

32
backend/check_admin.py Normal file
View File

@@ -0,0 +1,32 @@
"""Script para verificar información del admin"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
from app.models.user import User
import os
async def check_user():
database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
result = await session.execute(
select(User).where(User.email == 'admin@example.com')
)
user = result.scalar_one_or_none()
if user:
print(f'User found:')
print(f' Email: {user.email}')
print(f' Role: {user.role}')
print(f' Tenant ID: {user.tenant_id}')
print(f' User ID: {user.id}')
else:
print('User not found')
await engine.dispose()
if __name__ == "__main__":
asyncio.run(check_user())

View File

@@ -0,0 +1,32 @@
"""Script para verificar información del test_user"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
from app.models.user import User
import os
async def check_user():
database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
result = await session.execute(
select(User).where(User.email == 'test_user@example.com')
)
user = result.scalar_one_or_none()
if user:
print(f'User found:')
print(f' Email: {user.email}')
print(f' Role: {user.role}')
print(f' Tenant ID: {user.tenant_id}')
print(f' User ID: {user.id}')
else:
print('User not found')
await engine.dispose()
if __name__ == "__main__":
asyncio.run(check_user())

47
backend/check_ticket.py Normal file
View File

@@ -0,0 +1,47 @@
"""Script para verificar información del ticket"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
from app.models.ticket import Ticket
from app.models.user import User
import uuid
import os
async def check_ticket():
database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
ticket_id = '2bd79718-440d-4144-b660-c0c6051fcf73'
async with async_session() as session:
result = await session.execute(
select(Ticket).where(Ticket.id == uuid.UUID(ticket_id))
)
ticket = result.scalar_one_or_none()
if ticket:
creator_result = await session.execute(
select(User).where(User.id == ticket.created_by)
)
creator = creator_result.scalar_one_or_none()
print(f'Ticket found:')
print(f' ID: {ticket.id}')
print(f' Number: {ticket.ticket_number}')
print(f' Subject: {ticket.subject}')
print(f' Status: {ticket.status}')
print(f' Tenant ID: {ticket.tenant_id}')
print(f' Created by ID: {ticket.created_by}')
if creator:
print(f' Creator email: {creator.email}')
print(f' Creator role: {creator.role}')
print(f' Assigned to: {ticket.assigned_to}')
else:
print('Ticket not found')
await engine.dispose()
if __name__ == "__main__":
asyncio.run(check_ticket())

View File

@@ -0,0 +1,41 @@
"""Script para verificar números de tickets existentes"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
from app.models.ticket import Ticket
import os
async def check_tickets():
database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
tenant_id = 'c186c814-4f5a-4293-aae9-46f57637bb35'
async with async_session() as session:
result = await session.execute(
select(Ticket.ticket_number, Ticket.id, Ticket.subject)
.where(Ticket.tenant_id == tenant_id)
.order_by(Ticket.ticket_number)
)
tickets = result.all()
print(f"Tickets existentes para tenant {tenant_id}:")
print("=" * 80)
for ticket_number, ticket_id, subject in tickets:
print(f" {ticket_number} | {ticket_id} | {subject}")
print("=" * 80)
print(f"Total: {len(tickets)} tickets")
if tickets:
last_ticket = tickets[-1]
last_number = int(last_ticket[0].split('-')[1])
next_number = last_number + 1
print(f"\nÚltimo número: {last_ticket[0]} (número: {last_number})")
print(f"Próximo número debería ser: TK-{next_number:06d}")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(check_tickets())

31
backend/list_all_users.py Normal file
View File

@@ -0,0 +1,31 @@
"""Script para listar todos los usuarios"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
from app.models.user import User
import os
async def list_users():
database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
result = await session.execute(select(User))
users = result.scalars().all()
if users:
print(f'Found {len(users)} users:')
for user in users:
print(f'\n Email: {user.email}')
print(f' Role: {user.role}')
print(f' Tenant ID: {user.tenant_id}')
print(f' User ID: {user.id}')
else:
print('No users found')
await engine.dispose()
if __name__ == "__main__":
asyncio.run(list_users())

View File

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

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())

View File

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

View File

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

View File

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

View File

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

View File

@@ -430,7 +430,11 @@
</div>
<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}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" />
@@ -461,8 +465,9 @@
<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>
<label class="form-label">Razón Social</label>
<label class="form-label" for="business_name">Razón Social</label>
<input
id="business_name"
type="text"
class="form-input"
bind:value={businessProfile.business_name}
@@ -471,8 +476,9 @@
</div>
<div>
<label class="form-label">Nombre Comercial</label>
<label class="form-label" for="commercial_name">Nombre Comercial</label>
<input
id="commercial_name"
type="text"
class="form-input"
bind:value={businessProfile.commercial_name}
@@ -481,8 +487,9 @@
</div>
<div>
<label class="form-label">Clave de Cliente</label>
<label class="form-label" for="client_code">Clave de Cliente</label>
<input
id="client_code"
type="text"
class="form-input"
bind:value={businessProfile.client_code}
@@ -491,7 +498,7 @@
</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}>
<option value="">Seleccionar...</option>
<option value="corporativo">Corporativo</option>
@@ -503,8 +510,9 @@
</div>
<div>
<label class="form-label">RFC</label>
<label class="form-label" for="rfc">RFC</label>
<input
id="rfc"
type="text"
class="form-input {businessProfileErrors.rfc ? 'border-red-300' : ''}"
bind:value={businessProfile.rfc}
@@ -518,8 +526,9 @@
</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
id="tax_id"
type="text"
class="form-input"
bind:value={businessProfile.tax_id}
@@ -619,8 +628,11 @@
<h3 class="font-medium text-gray-900 mb-4">Representantes</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="form-label">Encargado/Representante</label>
<label class="form-label" for="company_representative"
>Encargado/Representante</label
>
<input
id="company_representative"
type="text"
class="form-input"
bind:value={businessProfile.company_representative}
@@ -629,8 +641,9 @@
</div>
<div>
<label class="form-label">Representante Legal</label>
<label class="form-label" for="legal_representative">Representante Legal</label>
<input
id="legal_representative"
type="text"
class="form-input"
bind:value={businessProfile.legal_representative}
@@ -645,7 +658,7 @@
<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>
<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}>
<option value="MXN">MXN - Peso Mexicano</option>
<option value="USD">USD - Dólar Americano</option>
@@ -654,12 +667,12 @@
</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} />
</div>
<div class="md:col-span-2">
<label class="form-label">Notas Adicionales</label>
<label class="form-label" for="notes">Notas Adicionales</label>
<textarea
class="form-input"
rows="3"
@@ -972,7 +985,11 @@
</div>
<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}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" />

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@servicemanager/internal-frontend",
"version": "0.1.0",
"version": "1.5.1",
"private": true,
"type": "module",
"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"
>
<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>
</button>
@@ -46,15 +51,23 @@
</span>
</div>
<span class="hidden sm:block text-sm">
{$auth.user?.first_name} {$auth.user?.last_name}
{$auth.user?.first_name}
{$auth.user?.last_name}
</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if isMenuOpen}
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50">
<div
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50"
>
<div class="py-1">
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
{$auth.user?.email}
@@ -62,7 +75,7 @@
<a
href="/profile"
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
</a>
@@ -84,6 +97,9 @@
{#if isMenuOpen}
<div
class="fixed inset-0 z-40 lg:hidden"
on:click={() => isMenuOpen = false}
></div>
role="dialog"
tabindex="0"
on:click={() => (isMenuOpen = false)}
on:keydown={e => e.key === 'Escape' && (isMenuOpen = false)}
/>
{/if}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
import { page } from '$app/stores';
import { auth } from '$lib/stores/auth.js';
export let open = false;
@@ -70,27 +70,46 @@
}
function isCurrentPage(href: string) {
return $page.url.pathname === href ||
($page.url.pathname.startsWith(href) && href !== '/');
return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
}
</script>
<!-- Mobile sidebar backdrop -->
{#if open}
<div class="fixed inset-0 z-40 lg:hidden">
<div class="fixed inset-0 bg-gray-600 bg-opacity-75" on:click={() => open = false}></div>
<div
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>
{/if}
<!-- Sidebar -->
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform {open ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0">
<div
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform {open
? 'translate-x-0'
: '-translate-x-full'} transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0"
>
<div class="flex flex-col h-full">
<!-- Logo -->
<div class="flex items-center justify-between h-16 px-6 bg-primary-600">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
<svg
class="w-5 h-5 text-primary-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<div class="text-white">
@@ -100,11 +119,16 @@
</div>
<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"
>
<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>
</button>
</div>
@@ -114,12 +138,12 @@
{#each navigation as item}
<a
href={item.href}
class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors {
isCurrentPage(item.href)
class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors {isCurrentPage(
item.href
)
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}"
on:click={() => open = false}
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'}"
on:click={() => (open = false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
@@ -139,12 +163,17 @@
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{$auth.user?.first_name} {$auth.user?.last_name}
{$auth.user?.first_name}
{$auth.user?.last_name}
</p>
<p class="text-xs text-gray-500 truncate">
{$auth.user?.role === 'ADMIN' ? 'Administrador' :
$auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' :
$auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'}
{$auth.user?.role === 'ADMIN'
? 'Administrador'
: $auth.user?.role === 'SUPPORT_MANAGER'
? 'Gerente de Soporte'
: $auth.user?.role === 'AGENT'
? 'Agente'
: 'Auditor'}
</p>
</div>
</div>

View File

@@ -25,11 +25,15 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
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(init.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
if (user && user.tenant_id) {
headers.set('X-Tenant-ID', user.tenant_id);
}
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
@@ -62,6 +66,50 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
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 = {
get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }),
@@ -76,5 +124,8 @@ export const api = {
request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) }),
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">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/utils/api';
import { page } from '$app/stores';
import { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api';
import { onMount } from 'svelte';
let ticketId: string;
let ticket = null;
let comments = [];
let attachments = [];
let users = [];
let isLoading = false;
let newComment = '';
@@ -38,18 +39,20 @@
// No mostrar error en polling silencioso
console.error('Error recargando comentarios:', e);
}
}
}
async function loadData() {
isLoading = true;
try {
const [ticketData, commentsData, usersData] = await Promise.all([
const [ticketData, commentsData, attachmentsData, usersData] = await Promise.all([
api.get(`/tickets/${ticketId}`),
api.get(`/tickets/${ticketId}/comments`),
api.get(`/tickets/${ticketId}/attachments`),
api.get('/users/')
]);
ticket = ticketData;
comments = commentsData;
attachments = attachmentsData;
users = usersData;
} catch (e) {
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(() => {
ticketId = $page.params.id;
if (ticketId) {
@@ -121,14 +137,13 @@
clearInterval(pollingInterval);
}
};
});
});
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
{#if isLoading}
<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>
</div>
{:else if ticket}
@@ -153,10 +168,18 @@
{ticket.subject || ticket.title}
</h1>
<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}
</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}
</span>
<span class="text-sm text-gray-500">
@@ -183,6 +206,74 @@
</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 -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200">
@@ -197,9 +288,16 @@
<div class="space-y-4">
{#each comments as comment}
<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">
{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>
</div>
<div class="flex-1 min-w-0">
@@ -234,7 +332,7 @@
placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment}
disabled={isSubmittingComment}
></textarea>
/>
<div class="flex justify-end">
<button
@@ -244,7 +342,9 @@
>
{#if isSubmittingComment}
<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>
</div>
{:else}
@@ -268,7 +368,9 @@
<div class="px-6 py-5 space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
<dd class="text-sm text-gray-900 font-mono">#{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>

View File

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