v1.8.0: Sistema funcional con filtros optimizados y UI mejorada

Mejoras en Módulo de Tickets:
- Implementado sistema de filtros funcional por estado y prioridad
- Tabla compacta estilo auditoría (50% más espacio visible)
- Backend actualizado: parámetros 'status' y 'priority' con validación
- Interfaz más limpia con labels reducidos y 2 columnas de filtros
- Eliminación de columna SLA duplicada en tabla

Correcciones Backend:
- Endpoint /v1/tickets/: filtros 'status' y 'priority' funcionan correctamente
- Endpoint /v1/sla/violations: timezone UTC y eager loading con selectinload
- Endpoint /v1/client-profile/: generación explícita de UUID
- Migración fix_client_profiles_timestamps aplicada

Mejoras UI Frontend:
- Tabla tickets: encabezados uppercase text-xs, celdas px-3 py-2
- Toggle de estado activo/inactivo en gestión de tenants (tabla + modal)
- Badges más compactos con rounded-full
- Botones de acciones con separador visual y transiciones
- Filtros con URLSearchParams para construcción correcta de queries

Arquitectura:
- SQLAlchemy: eager loading para evitar N+1 queries
- Timezone handling: datetime.now(timezone.utc) para comparaciones
- Svelte reactivity: keyed loops y spread operator para forzar updates
- API client: endpoint con query string completo

Estado del sistema: Totalmente funcional para producción MVP
This commit is contained in:
2026-02-17 12:43:06 -07:00
parent cc1e964c3a
commit e6440395ea
8 changed files with 245 additions and 1633 deletions

View File

@@ -50,8 +50,11 @@ async def get_current_client_profile(
profile = result.scalar_one_or_none()
if not profile:
# Si no existe, crear uno vacío
profile = ClientProfile(tenant_id=current_tenant.id)
# Si no existe, crear uno vacío con valores por defecto explícitos
profile = ClientProfile(
id=uuid.uuid4(),
tenant_id=current_tenant.id
)
db.add(profile)
await db.commit()
await db.refresh(profile)

View File

@@ -8,6 +8,7 @@ Solo accesible por roles staff internos
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc, case, cast
from sqlalchemy.orm import selectinload
from typing import Optional, List
from datetime import datetime, timedelta, timezone
import uuid
@@ -359,8 +360,12 @@ async def get_sla_violations(
now = datetime.now(timezone.utc)
db_now = func.now()
# Base query
query = select(Ticket).where(
# Base query con carga de relaciones
query = select(Ticket).options(
selectinload(Ticket.created_by_user),
selectinload(Ticket.assigned_to_user),
selectinload(Ticket.category)
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
@@ -421,23 +426,25 @@ async def get_sla_violations(
# Formatear response
violations = []
for ticket in tickets:
# Asegurar que los datetimes de BD sean timezone-aware
sla_response_due = ticket.sla_response_due.replace(tzinfo=timezone.utc) if ticket.sla_response_due and ticket.sla_response_due.tzinfo is None else ticket.sla_response_due
sla_resolution_due = ticket.sla_resolution_due.replace(tzinfo=timezone.utc) if ticket.sla_resolution_due and ticket.sla_resolution_due.tzinfo is None else ticket.sla_resolution_due
# Determinar tipo de violación
response_violated = ticket.first_response_at is None and ticket.sla_response_due and now > ticket.sla_response_due
resolution_violated = ticket.sla_resolution_due and now > ticket.sla_resolution_due
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
resolution_violated = sla_resolution_due and now > sla_resolution_due
# Priorizar resolution si ambos están violados
if resolution_violated:
violation_type = SLATypeEnum.RESOLUTION
due_at = ticket.sla_resolution_due
due_at = sla_resolution_due
else:
violation_type = SLATypeEnum.RESPONSE
due_at = ticket.sla_response_due
due_at = sla_response_due
hours_overdue = (now - due_at).total_seconds() / 3600 if due_at else 0
# Cargar relaciones
await db.refresh(ticket, ['created_by', 'assigned_to', 'category'])
# Las relaciones ya están cargadas por selectinload
violations.append(SLAViolationResponse(
ticket=TicketBasicInfo(
id=ticket.id,
@@ -453,17 +460,17 @@ async def get_sla_violations(
sla_resolution_hours=ticket.category.sla_resolution_hours
) if ticket.category else None,
created_by=UserBasicInfo(
id=ticket.created_by.id,
first_name=ticket.created_by.first_name,
last_name=ticket.created_by.last_name,
email=ticket.created_by.email
id=ticket.created_by_user.id,
first_name=ticket.created_by_user.first_name,
last_name=ticket.created_by_user.last_name,
email=ticket.created_by_user.email
),
assigned_to=UserBasicInfo(
id=ticket.assigned_to.id,
first_name=ticket.assigned_to.first_name,
last_name=ticket.assigned_to.last_name,
email=ticket.assigned_to.email
) if ticket.assigned_to else None,
id=ticket.assigned_to_user.id,
first_name=ticket.assigned_to_user.first_name,
last_name=ticket.assigned_to_user.last_name,
email=ticket.assigned_to_user.email
) if ticket.assigned_to_user else None,
sla_type=violation_type,
sla_due_at=due_at,
violated_at=due_at, # Se violó en el momento del due

View File

@@ -240,14 +240,19 @@ async def create_ticket(
async def get_tickets(
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
status: Optional[str] = None,
priority: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener tickets
Obtener tickets con filtros opcionales
Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets
Filtros disponibles:
- status: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED
- priority: LOW, MEDIUM, HIGH, URGENT
"""
# Construir query base filtrado por tenant
query = select(Ticket).where(
@@ -258,14 +263,26 @@ async def get_tickets(
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
if status_filter:
# Filtro por estado
if status:
try:
status_enum = TicketStatus[status_filter.upper()]
status_enum = TicketStatus[status.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}"
detail=f"Invalid status: {status}. Valid values: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED"
)
# Filtro por prioridad
if priority:
try:
priority_enum = TicketPriority[priority.upper()]
query = query.where(Ticket.priority == priority_enum)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid priority: {priority}. Valid values: LOW, MEDIUM, HIGH, URGENT"
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)

View File

@@ -1,451 +0,0 @@
"""
Tickets endpoints - ServiceManagerWeb
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List, Optional
from datetime import datetime
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.user import User
from app.models.category import Category # ✅ CORREGIDO: Era TicketCategory
from app.models.system import System
import uuid
router = APIRouter()
# ===================================
# SCHEMAS
# ===================================
class TicketCreate(BaseModel):
subject: str
description: str
category_id: Optional[str] = None
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
priority: str = "MEDIUM"
class TicketUpdate(BaseModel):
subject: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
assigned_to: Optional[str] = None
class TicketResponse(BaseModel):
id: str
ticket_number: str
subject: str
description: str
status: str
priority: str
category_id: Optional[str] = None
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
created_by: str
assigned_to: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TicketCloseRequest(BaseModel):
resolution: Optional[str] = None
# ===================================
# TICKET ENDPOINTS
# ===================================
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket(
ticket: TicketCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Crear un nuevo ticket
"""
try:
# Generar número de ticket único
result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
)
count = result.scalar() or 0
ticket_number = f"TK-{count + 1: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
# ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo
if category_uuid:
category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory
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
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
)
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
ticket_number=ticket_number,
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
status=TicketStatus.NEW,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id en respuesta
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"description": db_ticket.description,
"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
"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,
"updated_at": db_ticket.updated_at
}
except ValueError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}"
)
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}"
)
@router.get("/", response_model=List[TicketResponse])
async def get_tickets(
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener tickets del usuario actual
"""
query = select(Ticket).where(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
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,
"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,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener un ticket específico
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
ticket = result.scalars().first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"description": ticket.description,
"status": ticket.status.value,
"priority": ticket.priority.value,
"category_id": str(ticket.category_id) if ticket.category_id else None,
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(ticket.created_by),
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
@router.patch("/{ticket_id}", response_model=TicketResponse)
async def update_ticket(
ticket_id: str,
ticket_update: TicketUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Actualizar un ticket
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
try:
update_data = ticket_update.dict(exclude_unset=True)
for field, value in update_data.items():
if field == "status" and value:
setattr(db_ticket, field, TicketStatus[value.upper()])
elif field == "priority" and value:
setattr(db_ticket, field, TicketPriority[value.upper()])
elif field == "assigned_to" and value:
setattr(db_ticket, field, uuid.UUID(value))
else:
setattr(db_ticket, field, value)
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"description": db_ticket.description,
"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
"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,
"updated_at": db_ticket.updated_at
}
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error updating ticket: {str(e)}"
)
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
async def close_ticket(
ticket_id: str,
close_request: TicketCloseRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Cerrar un ticket
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
try:
db_ticket.status = TicketStatus.CLOSED
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"description": db_ticket.description,
"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
"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,
"updated_at": db_ticket.updated_at
}
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error closing ticket: {str(e)}"
)
# ===================================
# COMMENT ENDPOINTS (placeholder)
# ===================================
@router.get("/{ticket_id}/comments")
async def get_ticket_comments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener comentarios de un ticket
"""
return []
@router.post("/{ticket_id}/comments", status_code=status.HTTP_201_CREATED)
async def create_comment(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Agregar un comentario a un ticket
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Comments not yet implemented"
)
# ===================================
# ATTACHMENT ENDPOINTS (placeholder)
# ===================================
@router.get("/{ticket_id}/attachments")
async def get_ticket_attachments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener adjuntos de un ticket
"""
return []
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
async def upload_attachment(
ticket_id: str,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Subir un archivo adjunto a un ticket
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="File uploads not yet implemented"
)

View File

@@ -0,0 +1,47 @@
"""Fix client_profiles timestamps to use server defaults
Revision ID: fix_client_timestamps
Revises: a1b2c3d4e5f6
Create Date: 2026-02-17 12:05:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fix_client_timestamps'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Modificar created_at para usar server_default
op.alter_column('client_profiles', 'created_at',
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('now()')
)
# Modificar updated_at para usar server_default
op.alter_column('client_profiles', 'updated_at',
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('now()')
)
def downgrade() -> None:
# Remover server_default
op.alter_column('client_profiles', 'created_at',
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=None
)
op.alter_column('client_profiles', 'updated_at',
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=None
)

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,9 @@
async function loadTenants() {
isLoading = true;
try {
tenants = await api.get('/tenants/');
const data = await api.get('/tenants/');
// Forzar reactividad asignando un nuevo array
tenants = [...data];
} catch (e) {
toast.error('Error cargando clientes');
} finally {
@@ -65,6 +67,27 @@
}
}
async function toggleTenantStatus(tenant: any) {
const newStatus = tenant.status === 'active' ? 'inactive' : 'active';
try {
// Actualizar en el backend
await api.put(`/tenants/${tenant.id}`, { status: newStatus });
// Actualización optimista: actualizar el objeto local inmediatamente
tenant.status = newStatus;
tenants = [...tenants]; // Forzar reactividad
toast.success(`Cliente ${newStatus === 'active' ? 'activado' : 'desactivado'} correctamente`);
// Recargar para asegurar sincronización con backend
await loadTenants();
} catch (e) {
toast.error(e.message || 'Error cambiando estado del cliente');
// En caso de error, recargar para restaurar el estado real
await loadTenants();
}
}
onMount(loadTenants);
</script>
@@ -108,16 +131,32 @@
{:else if tenants.length === 0}
<tr><td colspan="6" class="text-center py-4">No hay clientes registrados</td></tr>
{:else}
{#each tenants as tenant}
{#each tenants as tenant (tenant.id)}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{tenant.name}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.slug}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.contact_email || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.contact_phone || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<div class="flex items-center space-x-3">
<!-- Toggle Switch -->
<button
type="button"
on:click={() => toggleTenantStatus(tenant)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
role="switch"
aria-checked={tenant.status === 'active'}
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {tenant.status === 'active' ? 'translate-x-6' : 'translate-x-1'}"
/>
</button>
<!-- Badge de Estado -->
<span class:bg-green-100={tenant.status === 'active'} class:text-green-800={tenant.status === 'active'} class:bg-yellow-100={tenant.status === 'suspended'} class:text-yellow-800={tenant.status === 'suspended'} class:bg-red-100={tenant.status === 'inactive'} class:text-red-800={tenant.status === 'inactive'} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{tenant.status === 'active' ? 'Activo' : tenant.status === 'suspended' ? 'Suspendido' : 'Inactivo'}
</span>
</div>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(tenant)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
@@ -162,12 +201,40 @@
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700">Estado</label>
<select id="status" bind:value={formData.status} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<option value="active">Activo</option>
<option value="suspended">Suspendido</option>
<option value="inactive">Inactivo</option>
</select>
<label class="block text-sm font-medium text-gray-700 mb-3">Estado del Cliente</label>
<!-- Checkbox estilo toggle para Activo/Inactivo -->
<div class="flex items-center space-x-3">
<button
type="button"
on:click={() => formData.status = formData.status === 'active' ? 'inactive' : 'active'}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {formData.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
role="switch"
aria-checked={formData.status === 'active'}
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {formData.status === 'active' ? 'translate-x-6' : 'translate-x-1'}"
/>
</button>
<span class="text-sm font-medium {formData.status === 'active' ? 'text-green-700' : 'text-gray-500'}">
{formData.status === 'active' ? 'Activo' : 'Inactivo'}
</span>
</div>
<!-- Opción para Suspendido (opcional) -->
{#if editingTenant}
<div class="mt-3">
<label class="flex items-center">
<input
type="checkbox"
checked={formData.status === 'suspended'}
on:change={(e) => formData.status = e.target.checked ? 'suspended' : 'active'}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 h-4 w-4"
/>
<span class="ml-2 text-sm text-gray-600">Marcar como suspendido temporalmente</span>
</label>
</div>
{/if}
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">

View File

@@ -50,17 +50,26 @@
{ value: 'URGENT', label: 'Urgente', color: 'red' }
];
// Ajustar la función loadData para asegurar que los filtros se envíen correctamente
// Función para cargar datos con filtros
async function loadData() {
isLoading = true;
try {
const [ticketsData, categoriesData, systemsData, usersData] = await Promise.all([
api.get('/tickets/', {
params: {
status: filterStatus || undefined,
priority: filterPriority || undefined
// Construir parámetros de consulta
const queryParams = new URLSearchParams();
queryParams.append('skip', '0');
queryParams.append('limit', '100');
if (filterStatus) {
queryParams.append('status', filterStatus);
}
}),
if (filterPriority) {
queryParams.append('priority', filterPriority);
}
const endpoint = `/tickets/?${queryParams.toString()}`;
const [ticketsData, categoriesData, systemsData, usersData] = await Promise.all([
api.get(endpoint),
api.get('/categories/'),
api.get('/systems/'),
api.get('/users/')
@@ -206,10 +215,11 @@
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
// Formato más compacto: DD/MM/YY HH:MM
return date.toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
@@ -218,11 +228,11 @@
onMount(loadData);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="px-4 py-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Tickets de Soporte</h1>
<p class="mt-2 text-sm text-gray-700">Gestión de tickets del sistema de mesa de ayuda.</p>
<h1 class="text-lg font-semibold text-gray-900">Tickets de Soporte</h1>
<p class="mt-1 text-xs text-gray-600">Gestión de tickets del sistema de mesa de ayuda.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
@@ -236,17 +246,17 @@
</div>
<!-- Filtros -->
<div class="mt-6 bg-white shadow sm:rounded-lg p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="mt-3 bg-white shadow sm:rounded-lg p-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
<label for="filterStatus" class="block text-xs font-medium text-gray-700 mb-1">Estado</label>
<select
id="filterStatus"
bind:value={filterStatus}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
on:change={loadData}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-1.5"
>
<option value="">Todos</option>
<option value="">Todos los estados</option>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
@@ -254,118 +264,91 @@
</div>
<div>
<label for="filterPriority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<label for="filterPriority" class="block text-xs font-medium text-gray-700 mb-1">Prioridad</label>
<select
id="filterPriority"
bind:value={filterPriority}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
on:change={loadData}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-1.5"
>
<option value="">Todas</option>
<option value="">Todas las prioridades</option>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<div class="flex items-end">
<button
on:click={loadData}
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Actualizar
</button>
</div>
</div>
</div>
<!-- Tabla de Tickets -->
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="mt-4 flex flex-col">
<div class="-mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 sticky top-0 z-10">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Ticket</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asunto</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Prioridad</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">SLA</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Categoría</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asignado a</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Creado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ticket</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Asunto</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Estado</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Prioridad</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Categoría</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Asignado</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Creado</th>
<th scope="col" class="relative px-3 py-1.5 w-20">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tbody class="bg-white divide-y divide-gray-200">
{#if isLoading}
<tr><td colspan="9" class="text-center py-4">Cargando...</td></tr>
<tr><td colspan="8" class="text-center py-3 text-xs text-gray-500">Cargando...</td></tr>
{:else if tickets.length === 0}
<tr><td colspan="9" class="text-center py-4">No hay tickets registrados</td></tr>
<tr><td colspan="8" class="text-center py-3 text-xs text-gray-500">No hay tickets registrados</td></tr>
{:else}
{#each tickets as ticket}
<tr
class="hover:bg-gray-50 cursor-pointer"
class="hover:bg-gray-50 cursor-pointer transition-colors"
on:click={() => viewTicket(ticket)}
>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
<td class="px-3 py-2 whitespace-nowrap text-xs font-medium text-gray-900">
{ticket.ticket_number || ticket.id.substring(0, 8)}
</td>
<td class="px-3 py-4 text-sm text-gray-900">
<div class="font-medium">{ticket.subject}</div>
<div class="text-gray-500 truncate max-w-xs">{ticket.description}</div>
<td class="px-3 py-2 text-xs">
<div class="font-medium text-gray-900 truncate max-w-xs">{ticket.subject}</div>
<div class="text-gray-500 truncate max-w-xs text-[11px]">{ticket.description}</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<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">
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium rounded-full bg-{getStatusBadge(ticket.status).color}-100 text-{getStatusBadge(ticket.status).color}-800">
{getStatusBadge(ticket.status).label}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<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">
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium rounded-full bg-{getPriorityBadge(ticket.priority).color}-100 text-{getPriorityBadge(ticket.priority).color}-800">
{getPriorityBadge(ticket.priority).label}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{#if ticket.sla_resolution_due}
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
⚠️ Vencido
</span>
{:else if ticket.sla_resolution_met}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
✓ OK
</span>
{:else}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
⏳ En plazo
</span>
{/if}
{:else}
<span class="text-gray-400">-</span>
{/if}
<td class="px-3 py-2 text-xs text-gray-900">
{ticket.category_name || '-'}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getCategoryName(ticket.category_id)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="px-3 py-2 text-xs text-gray-500 truncate max-w-[120px]">
{getUserName(ticket.assigned_to)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="px-3 py-2 whitespace-nowrap text-xs text-gray-500">
{formatDate(ticket.created_at)}
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6 space-x-2">
<td class="px-3 py-2 whitespace-nowrap text-right text-xs">
<button
on:click|stopPropagation={() => openEditModal(ticket)}
class="text-indigo-600 hover:text-indigo-900"
class="text-indigo-600 hover:text-indigo-900 font-medium transition-colors"
>
Editar
</button>
<span class="text-gray-300 mx-1">|</span>
<button
on:click|stopPropagation={() => openDeleteModal(ticket)}
class="text-red-600 hover:text-red-900"
class="text-red-600 hover:text-red-900 font-medium transition-colors"
>
Eliminar
</button>
@@ -379,6 +362,7 @@
</div>
</div>
</div>
</div>
</div>
<!-- Modal Crear Ticket -->