🚀 Release v1.4.1 - Enhanced Ticket Management Features
✨ New Features: • Added admin filter by client/organization in internal frontend • Enhanced attachments viewer with toggle button in client frontend • Added attachment counter and improved UX in ticket conversation 🔧 Backend Improvements: • New `/tickets/admin/all` endpoint for administrators • Advanced filtering by tenant, status, and priority • Rich response data with tenant and user information • Proper admin permissions validation 🎨 Frontend Enhancements: • Client filter dropdown in admin panel • Enhanced ticket table with client/organization info • Collapsible attachments section with visual indicator • Improved API parameter handling (fixed undefined filters) • Better responsive design and hover effects 🐛 Bug Fixes: • Fixed undefined URL parameters in API calls • Corrected parameter filtering in API utility • Improved error handling for admin endpoints 📱 UI/UX: • Added visual badges for attachment count • Enhanced table columns with client/creator information • Improved button styling and interaction feedback • Better organization of ticket conversation layout
This commit is contained in:
@@ -210,6 +210,148 @@ async def get_tickets(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/all", response_model=List[dict])
|
||||
async def get_all_tickets_admin(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status_filter: Optional[str] = None,
|
||||
priority_filter: Optional[str] = None,
|
||||
tenant_id_filter: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Obtener todos los tickets de todos los tenants (solo para administradores)
|
||||
Incluye información del tenant y usuario que creó el ticket
|
||||
"""
|
||||
# Verificar que el usuario sea administrador
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No tienes permisos para acceder a esta función"
|
||||
)
|
||||
|
||||
# Query base con joins para obtener información del tenant y usuario creador
|
||||
query = select(Ticket, Tenant, User).join(
|
||||
Tenant, Ticket.tenant_id == Tenant.id
|
||||
).join(
|
||||
User, Ticket.created_by == User.id
|
||||
)
|
||||
|
||||
# Aplicar filtros
|
||||
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}"
|
||||
)
|
||||
|
||||
if priority_filter:
|
||||
try:
|
||||
priority_enum = TicketPriority[priority_filter.upper()]
|
||||
query = query.where(Ticket.priority == priority_enum)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid priority: {priority_filter}"
|
||||
)
|
||||
|
||||
if tenant_id_filter:
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(tenant_id_filter)
|
||||
query = query.where(Ticket.tenant_id == tenant_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid tenant ID format"
|
||||
)
|
||||
|
||||
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(ticket.id),
|
||||
"ticket_number": ticket.ticket_number,
|
||||
"subject": ticket.subject,
|
||||
"title": 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,
|
||||
"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,
|
||||
# Información del tenant/cliente
|
||||
"tenant": {
|
||||
"id": str(tenant.id),
|
||||
"name": tenant.name,
|
||||
"slug": tenant.slug,
|
||||
"contact_email": tenant.contact_email
|
||||
},
|
||||
# Información del usuario creador
|
||||
"created_by_user": {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"role": user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
}
|
||||
}
|
||||
for ticket, tenant, user in rows
|
||||
]
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
let fileInput: HTMLInputElement;
|
||||
let isUploading = false;
|
||||
let pollingInterval: any = null;
|
||||
let showAttachments = true; // Variable para controlar la visibilidad de attachments
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
@@ -257,42 +258,59 @@
|
||||
{#if $tickets.attachments.length > 0}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Archivos Adjuntos</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="space-y-3">
|
||||
{#each $tickets.attachments as attachment}
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{attachment.original_filename}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{Math.round(attachment.size_bytes / 1024)} KB •
|
||||
Subido por {attachment.uploaded_by_name} •
|
||||
{formatDate(attachment.uploaded_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/api/v1/tickets/{ticketId}/attachments/{attachment.id}/download"
|
||||
class="btn-ghost p-2"
|
||||
target="_blank"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
<div 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>
|
||||
</h3>
|
||||
<button
|
||||
class="text-sm text-gray-500 hover:text-gray-700"
|
||||
title="Ver/Ocultar archivos adjuntos"
|
||||
on:click={() => showAttachments = !showAttachments}
|
||||
>
|
||||
{showAttachments ? 'Ocultar' : 'Ver'} archivos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if showAttachments}
|
||||
<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 space-x-3">
|
||||
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{attachment.original_filename}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{Math.round(attachment.size_bytes / 1024)} KB •
|
||||
Subido por {attachment.uploaded_by_name} •
|
||||
{formatDate(attachment.uploaded_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/api/v1/tickets/{ticketId}/attachments/{attachment.id}/download"
|
||||
class="btn-ghost p-2 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" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -12,8 +12,15 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
||||
|
||||
let url = `${API_BASE}${endpoint}`;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
url += `?${searchParams.toString()}`;
|
||||
// Filtrar parámetros undefined y null
|
||||
const filteredParams = Object.entries(params)
|
||||
.filter(([key, value]) => value !== undefined && value !== null && value !== '')
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
|
||||
|
||||
if (Object.keys(filteredParams).length > 0) {
|
||||
const searchParams = new URLSearchParams(filteredParams);
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
const authState = get(auth);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
let categories = [];
|
||||
let systems = [];
|
||||
let users = [];
|
||||
let tenants = []; // Nueva lista de tenants
|
||||
let isLoading = false;
|
||||
let showModal = false;
|
||||
let showEditModal = false;
|
||||
@@ -18,6 +19,7 @@
|
||||
// Filtros
|
||||
let filterStatus = '';
|
||||
let filterPriority = '';
|
||||
let filterTenant = ''; // Nuevo filtro por cliente/tenant
|
||||
|
||||
// Form para editar
|
||||
let editFormData = {
|
||||
@@ -50,25 +52,29 @@
|
||||
{ value: 'URGENT', label: 'Urgente', color: 'red' }
|
||||
];
|
||||
|
||||
// Ajustar la función loadData para asegurar que los filtros se envíen correctamente
|
||||
// Ajustar la función loadData para usar el endpoint administrativo 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
|
||||
}
|
||||
}),
|
||||
// Preparar parámetros filtrando valores vacíos
|
||||
const ticketParams = {};
|
||||
if (filterStatus) ticketParams.status_filter = filterStatus;
|
||||
if (filterPriority) ticketParams.priority_filter = filterPriority;
|
||||
if (filterTenant) ticketParams.tenant_id_filter = filterTenant;
|
||||
|
||||
const [ticketsData, categoriesData, systemsData, usersData, tenantsData] = await Promise.all([
|
||||
// Usar el nuevo endpoint administrativo
|
||||
api.get('/tickets/admin/all', ticketParams),
|
||||
api.get('/categories/'),
|
||||
api.get('/systems/'),
|
||||
api.get('/users/')
|
||||
api.get('/users/'),
|
||||
api.get('/tenants/') // Cargar lista de tenants
|
||||
]);
|
||||
tickets = ticketsData;
|
||||
categories = categoriesData;
|
||||
systems = systemsData;
|
||||
users = usersData;
|
||||
tenants = tenantsData;
|
||||
} catch (e) {
|
||||
toast.error('Error cargando datos: ' + (e.message || 'Error desconocido'));
|
||||
} finally {
|
||||
@@ -237,7 +243,22 @@
|
||||
|
||||
<!-- 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="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<label for="filterTenant" class="block text-sm font-medium text-gray-700">Cliente/Empresa</label>
|
||||
<select
|
||||
id="filterTenant"
|
||||
bind:value={filterTenant}
|
||||
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"
|
||||
>
|
||||
<option value="">Todos los clientes</option>
|
||||
{#each tenants as tenant}
|
||||
<option value={tenant.id}>{tenant.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
|
||||
<select
|
||||
@@ -288,12 +309,13 @@
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Ticket</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente/Empresa</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">Categoría</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Creado por</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="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Fecha</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">Acciones</span>
|
||||
</th>
|
||||
@@ -301,9 +323,9 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#if isLoading}
|
||||
<tr><td colspan="8" class="text-center py-4">Cargando...</td></tr>
|
||||
<tr><td colspan="9" class="text-center py-4">Cargando...</td></tr>
|
||||
{:else if tickets.length === 0}
|
||||
<tr><td colspan="8" class="text-center py-4">No hay tickets registrados</td></tr>
|
||||
<tr><td colspan="9" class="text-center py-4">No hay tickets registrados</td></tr>
|
||||
{:else}
|
||||
{#each tickets as ticket}
|
||||
<tr
|
||||
@@ -313,6 +335,10 @@
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||
{ticket.ticket_number || ticket.id.substring(0, 8)}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-900">
|
||||
<div class="font-medium text-indigo-600">{ticket.tenant?.name || 'N/A'}</div>
|
||||
<div class="text-xs text-gray-500">{ticket.tenant?.contact_email || ''}</div>
|
||||
</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>
|
||||
@@ -327,8 +353,10 @@
|
||||
{getPriorityBadge(ticket.priority).label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{getCategoryName(ticket.category_id)}
|
||||
<td class="px-3 py-4 text-sm text-gray-900">
|
||||
<div class="font-medium">{ticket.created_by_user?.first_name} {ticket.created_by_user?.last_name}</div>
|
||||
<div class="text-xs text-gray-500">{ticket.created_by_user?.email}</div>
|
||||
<div class="text-xs text-indigo-600">{ticket.created_by_user?.role || ''}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{getUserName(ticket.assigned_to)}
|
||||
|
||||
Reference in New Issue
Block a user