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:
@@ -50,8 +50,11 @@ async def get_current_client_profile(
|
|||||||
profile = result.scalar_one_or_none()
|
profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not profile:
|
if not profile:
|
||||||
# Si no existe, crear uno vacío
|
# Si no existe, crear uno vacío con valores por defecto explícitos
|
||||||
profile = ClientProfile(tenant_id=current_tenant.id)
|
profile = ClientProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=current_tenant.id
|
||||||
|
)
|
||||||
db.add(profile)
|
db.add(profile)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(profile)
|
await db.refresh(profile)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Solo accesible por roles staff internos
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, and_, or_, desc, case, cast
|
from sqlalchemy import select, func, and_, or_, desc, case, cast
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import uuid
|
import uuid
|
||||||
@@ -359,8 +360,12 @@ async def get_sla_violations(
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
db_now = func.now()
|
db_now = func.now()
|
||||||
|
|
||||||
# Base query
|
# Base query con carga de relaciones
|
||||||
query = select(Ticket).where(
|
query = select(Ticket).options(
|
||||||
|
selectinload(Ticket.created_by_user),
|
||||||
|
selectinload(Ticket.assigned_to_user),
|
||||||
|
selectinload(Ticket.category)
|
||||||
|
).where(
|
||||||
and_(
|
and_(
|
||||||
Ticket.tenant_id == current_tenant.id,
|
Ticket.tenant_id == current_tenant.id,
|
||||||
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
|
||||||
@@ -421,23 +426,25 @@ async def get_sla_violations(
|
|||||||
# Formatear response
|
# Formatear response
|
||||||
violations = []
|
violations = []
|
||||||
for ticket in tickets:
|
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
|
# Determinar tipo de violación
|
||||||
response_violated = ticket.first_response_at is None and ticket.sla_response_due and now > ticket.sla_response_due
|
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
|
||||||
resolution_violated = ticket.sla_resolution_due and now > ticket.sla_resolution_due
|
resolution_violated = sla_resolution_due and now > sla_resolution_due
|
||||||
|
|
||||||
# Priorizar resolution si ambos están violados
|
# Priorizar resolution si ambos están violados
|
||||||
if resolution_violated:
|
if resolution_violated:
|
||||||
violation_type = SLATypeEnum.RESOLUTION
|
violation_type = SLATypeEnum.RESOLUTION
|
||||||
due_at = ticket.sla_resolution_due
|
due_at = sla_resolution_due
|
||||||
else:
|
else:
|
||||||
violation_type = SLATypeEnum.RESPONSE
|
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
|
hours_overdue = (now - due_at).total_seconds() / 3600 if due_at else 0
|
||||||
|
|
||||||
# Cargar relaciones
|
# Las relaciones ya están cargadas por selectinload
|
||||||
await db.refresh(ticket, ['created_by', 'assigned_to', 'category'])
|
|
||||||
|
|
||||||
violations.append(SLAViolationResponse(
|
violations.append(SLAViolationResponse(
|
||||||
ticket=TicketBasicInfo(
|
ticket=TicketBasicInfo(
|
||||||
id=ticket.id,
|
id=ticket.id,
|
||||||
@@ -453,17 +460,17 @@ async def get_sla_violations(
|
|||||||
sla_resolution_hours=ticket.category.sla_resolution_hours
|
sla_resolution_hours=ticket.category.sla_resolution_hours
|
||||||
) if ticket.category else None,
|
) if ticket.category else None,
|
||||||
created_by=UserBasicInfo(
|
created_by=UserBasicInfo(
|
||||||
id=ticket.created_by.id,
|
id=ticket.created_by_user.id,
|
||||||
first_name=ticket.created_by.first_name,
|
first_name=ticket.created_by_user.first_name,
|
||||||
last_name=ticket.created_by.last_name,
|
last_name=ticket.created_by_user.last_name,
|
||||||
email=ticket.created_by.email
|
email=ticket.created_by_user.email
|
||||||
),
|
),
|
||||||
assigned_to=UserBasicInfo(
|
assigned_to=UserBasicInfo(
|
||||||
id=ticket.assigned_to.id,
|
id=ticket.assigned_to_user.id,
|
||||||
first_name=ticket.assigned_to.first_name,
|
first_name=ticket.assigned_to_user.first_name,
|
||||||
last_name=ticket.assigned_to.last_name,
|
last_name=ticket.assigned_to_user.last_name,
|
||||||
email=ticket.assigned_to.email
|
email=ticket.assigned_to_user.email
|
||||||
) if ticket.assigned_to else None,
|
) if ticket.assigned_to_user else None,
|
||||||
sla_type=violation_type,
|
sla_type=violation_type,
|
||||||
sla_due_at=due_at,
|
sla_due_at=due_at,
|
||||||
violated_at=due_at, # Se violó en el momento del due
|
violated_at=due_at, # Se violó en el momento del due
|
||||||
|
|||||||
@@ -240,14 +240,19 @@ async def create_ticket(
|
|||||||
async def get_tickets(
|
async def get_tickets(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
status_filter: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
|
priority: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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 ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant
|
||||||
Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets
|
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
|
# Construir query base filtrado por tenant
|
||||||
query = select(Ticket).where(
|
query = select(Ticket).where(
|
||||||
@@ -258,14 +263,26 @@ async def get_tickets(
|
|||||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||||
query = query.where(Ticket.created_by == current_user.id)
|
query = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
if status_filter:
|
# Filtro por estado
|
||||||
|
if status:
|
||||||
try:
|
try:
|
||||||
status_enum = TicketStatus[status_filter.upper()]
|
status_enum = TicketStatus[status.upper()]
|
||||||
query = query.where(Ticket.status == status_enum)
|
query = query.where(Ticket.status == status_enum)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
|
||||||
@@ -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
@@ -22,7 +22,9 @@
|
|||||||
async function loadTenants() {
|
async function loadTenants() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
tenants = await api.get('/tenants/');
|
const data = await api.get('/tenants/');
|
||||||
|
// Forzar reactividad asignando un nuevo array
|
||||||
|
tenants = [...data];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('Error cargando clientes');
|
toast.error('Error cargando clientes');
|
||||||
} finally {
|
} 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);
|
onMount(loadTenants);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -108,16 +131,32 @@
|
|||||||
{:else if tenants.length === 0}
|
{:else if tenants.length === 0}
|
||||||
<tr><td colspan="6" class="text-center py-4">No hay clientes registrados</td></tr>
|
<tr><td colspan="6" class="text-center py-4">No hay clientes registrados</td></tr>
|
||||||
{:else}
|
{:else}
|
||||||
{#each tenants as tenant}
|
{#each tenants as tenant (tenant.id)}
|
||||||
<tr>
|
<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 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.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_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">{tenant.contact_phone || '-'}</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<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">
|
<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'}
|
{tenant.status === 'active' ? 'Activo' : tenant.status === 'suspended' ? 'Suspendido' : 'Inactivo'}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
<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>
|
<button on:click={() => openEditModal(tenant)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||||
@@ -162,12 +201,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="status" class="block text-sm font-medium text-gray-700">Estado</label>
|
<label class="block text-sm font-medium text-gray-700 mb-3">Estado del Cliente</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>
|
<!-- Checkbox estilo toggle para Activo/Inactivo -->
|
||||||
<option value="suspended">Suspendido</option>
|
<div class="flex items-center space-x-3">
|
||||||
<option value="inactive">Inactivo</option>
|
<button
|
||||||
</select>
|
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>
|
||||||
|
|
||||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||||
|
|||||||
@@ -50,17 +50,26 @@
|
|||||||
{ value: 'URGENT', label: 'Urgente', color: 'red' }
|
{ 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() {
|
async function loadData() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const [ticketsData, categoriesData, systemsData, usersData] = await Promise.all([
|
// Construir parámetros de consulta
|
||||||
api.get('/tickets/', {
|
const queryParams = new URLSearchParams();
|
||||||
params: {
|
queryParams.append('skip', '0');
|
||||||
status: filterStatus || undefined,
|
queryParams.append('limit', '100');
|
||||||
priority: filterPriority || undefined
|
|
||||||
|
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('/categories/'),
|
||||||
api.get('/systems/'),
|
api.get('/systems/'),
|
||||||
api.get('/users/')
|
api.get('/users/')
|
||||||
@@ -206,10 +215,11 @@
|
|||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
if (!dateString) return '-';
|
if (!dateString) return '-';
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
// Formato más compacto: DD/MM/YY HH:MM
|
||||||
return date.toLocaleDateString('es-ES', {
|
return date.toLocaleDateString('es-ES', {
|
||||||
year: 'numeric',
|
year: '2-digit',
|
||||||
month: 'short',
|
month: '2-digit',
|
||||||
day: 'numeric',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
@@ -218,11 +228,11 @@
|
|||||||
onMount(loadData);
|
onMount(loadData);
|
||||||
</script>
|
</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 sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-xl font-semibold text-gray-900">Tickets de Soporte</h1>
|
<h1 class="text-lg 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>
|
<p class="mt-1 text-xs text-gray-600">Gestión de tickets del sistema de mesa de ayuda.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
<button
|
<button
|
||||||
@@ -236,17 +246,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtros -->
|
<!-- Filtros -->
|
||||||
<div class="mt-6 bg-white shadow sm:rounded-lg p-4">
|
<div class="mt-3 bg-white shadow sm:rounded-lg p-3">
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<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
|
<select
|
||||||
id="filterStatus"
|
id="filterStatus"
|
||||||
bind:value={filterStatus}
|
bind:value={filterStatus}
|
||||||
on:change={applyFilters}
|
on:change={loadData}
|
||||||
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"
|
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}
|
{#each STATUSES as status}
|
||||||
<option value={status.value}>{status.label}</option>
|
<option value={status.value}>{status.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -254,118 +264,91 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<select
|
||||||
id="filterPriority"
|
id="filterPriority"
|
||||||
bind:value={filterPriority}
|
bind:value={filterPriority}
|
||||||
on:change={applyFilters}
|
on:change={loadData}
|
||||||
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"
|
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}
|
{#each PRIORITIES as priority}
|
||||||
<option value={priority.value}>{priority.label}</option>
|
<option value={priority.value}>{priority.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabla de Tickets -->
|
<!-- Tabla de Tickets -->
|
||||||
<div class="mt-8 flex flex-col">
|
<div class="mt-4 flex flex-col">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<div class="-mx-4 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="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">
|
<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">
|
<div class="overflow-x-auto">
|
||||||
<thead class="bg-gray-50">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50 sticky top-0 z-10">
|
||||||
<tr>
|
<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-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">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-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">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-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">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-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">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-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Categoría</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-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Asignado</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-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Creado</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 px-3 py-1.5 w-20">
|
||||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
|
||||||
<span class="sr-only">Acciones</span>
|
<span class="sr-only">Acciones</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
{#if isLoading}
|
{#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}
|
{: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}
|
{:else}
|
||||||
{#each tickets as ticket}
|
{#each tickets as ticket}
|
||||||
<tr
|
<tr
|
||||||
class="hover:bg-gray-50 cursor-pointer"
|
class="hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
on:click={() => viewTicket(ticket)}
|
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)}
|
{ticket.ticket_number || ticket.id.substring(0, 8)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-4 text-sm text-gray-900">
|
<td class="px-3 py-2 text-xs">
|
||||||
<div class="font-medium">{ticket.subject}</div>
|
<div class="font-medium text-gray-900 truncate max-w-xs">{ticket.subject}</div>
|
||||||
<div class="text-gray-500 truncate max-w-xs">{ticket.description}</div>
|
<div class="text-gray-500 truncate max-w-xs text-[11px]">{ticket.description}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm">
|
<td class="px-3 py-2 whitespace-nowrap">
|
||||||
<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="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}
|
{getStatusBadge(ticket.status).label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm">
|
<td class="px-3 py-2 whitespace-nowrap">
|
||||||
<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="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}
|
{getPriorityBadge(ticket.priority).label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="px-3 py-2 text-xs text-gray-900">
|
||||||
{#if ticket.sla_resolution_due}
|
{ticket.category_name || '-'}
|
||||||
{#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>
|
</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]">
|
||||||
{getCategoryName(ticket.category_id)}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
|
||||||
{getUserName(ticket.assigned_to)}
|
{getUserName(ticket.assigned_to)}
|
||||||
</td>
|
</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)}
|
{formatDate(ticket.created_at)}
|
||||||
</td>
|
</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
|
<button
|
||||||
on:click|stopPropagation={() => openEditModal(ticket)}
|
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
|
Editar
|
||||||
</button>
|
</button>
|
||||||
|
<span class="text-gray-300 mx-1">|</span>
|
||||||
<button
|
<button
|
||||||
on:click|stopPropagation={() => openDeleteModal(ticket)}
|
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
|
Eliminar
|
||||||
</button>
|
</button>
|
||||||
@@ -380,6 +363,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal Crear Ticket -->
|
<!-- Modal Crear Ticket -->
|
||||||
<Modal open={showModal} title="Nuevo Ticket" on:close={() => showModal = false}>
|
<Modal open={showModal} title="Nuevo Ticket" on:close={() => showModal = false}>
|
||||||
|
|||||||
Reference in New Issue
Block a user