🚀 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)
|
@router.get("/{ticket_id}", response_model=TicketResponse)
|
||||||
async def get_ticket(
|
async def get_ticket(
|
||||||
ticket_id: str,
|
ticket_id: str,
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
let closeResolution = '';
|
let closeResolution = '';
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
let isUploading = false;
|
let isUploading = false;
|
||||||
let pollingInterval: any = null;
|
let pollingInterval: any = null;
|
||||||
|
let showAttachments = true; // Variable para controlar la visibilidad de attachments
|
||||||
|
|
||||||
async function loadComments() {
|
async function loadComments() {
|
||||||
try {
|
try {
|
||||||
@@ -257,42 +258,59 @@
|
|||||||
{#if $tickets.attachments.length > 0}
|
{#if $tickets.attachments.length > 0}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Archivos Adjuntos</h3>
|
<div class="flex items-center justify-between">
|
||||||
</div>
|
<h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
|
||||||
<div class="card-content">
|
<span>Archivos Adjuntos</span>
|
||||||
<div class="space-y-3">
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
{#each $tickets.attachments as attachment}
|
{$tickets.attachments.length} archivo{$tickets.attachments.length !== 1 ? 's' : ''}
|
||||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
</span>
|
||||||
<div class="flex items-center space-x-3">
|
</h3>
|
||||||
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
|
<button
|
||||||
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="text-sm text-gray-500 hover:text-gray-700"
|
||||||
<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" />
|
title="Ver/Ocultar archivos adjuntos"
|
||||||
</svg>
|
on:click={() => showAttachments = !showAttachments}
|
||||||
</div>
|
>
|
||||||
<div>
|
{showAttachments ? 'Ocultar' : 'Ver'} archivos
|
||||||
<p class="text-sm font-medium text-gray-900">
|
</button>
|
||||||
{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>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
|||||||
|
|
||||||
let url = `${API_BASE}${endpoint}`;
|
let url = `${API_BASE}${endpoint}`;
|
||||||
if (params) {
|
if (params) {
|
||||||
const searchParams = new URLSearchParams(params);
|
// Filtrar parámetros undefined y null
|
||||||
url += `?${searchParams.toString()}`;
|
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);
|
const authState = get(auth);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
let categories = [];
|
let categories = [];
|
||||||
let systems = [];
|
let systems = [];
|
||||||
let users = [];
|
let users = [];
|
||||||
|
let tenants = []; // Nueva lista de tenants
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let showModal = false;
|
let showModal = false;
|
||||||
let showEditModal = false;
|
let showEditModal = false;
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
// Filtros
|
// Filtros
|
||||||
let filterStatus = '';
|
let filterStatus = '';
|
||||||
let filterPriority = '';
|
let filterPriority = '';
|
||||||
|
let filterTenant = ''; // Nuevo filtro por cliente/tenant
|
||||||
|
|
||||||
// Form para editar
|
// Form para editar
|
||||||
let editFormData = {
|
let editFormData = {
|
||||||
@@ -50,25 +52,29 @@
|
|||||||
{ 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
|
// Ajustar la función loadData para usar el endpoint administrativo con filtros
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const [ticketsData, categoriesData, systemsData, usersData] = await Promise.all([
|
// Preparar parámetros filtrando valores vacíos
|
||||||
api.get('/tickets/', {
|
const ticketParams = {};
|
||||||
params: {
|
if (filterStatus) ticketParams.status_filter = filterStatus;
|
||||||
status: filterStatus || undefined,
|
if (filterPriority) ticketParams.priority_filter = filterPriority;
|
||||||
priority: filterPriority || undefined
|
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('/categories/'),
|
||||||
api.get('/systems/'),
|
api.get('/systems/'),
|
||||||
api.get('/users/')
|
api.get('/users/'),
|
||||||
|
api.get('/tenants/') // Cargar lista de tenants
|
||||||
]);
|
]);
|
||||||
tickets = ticketsData;
|
tickets = ticketsData;
|
||||||
categories = categoriesData;
|
categories = categoriesData;
|
||||||
systems = systemsData;
|
systems = systemsData;
|
||||||
users = usersData;
|
users = usersData;
|
||||||
|
tenants = tenantsData;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('Error cargando datos: ' + (e.message || 'Error desconocido'));
|
toast.error('Error cargando datos: ' + (e.message || 'Error desconocido'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -237,7 +243,22 @@
|
|||||||
|
|
||||||
<!-- Filtros -->
|
<!-- Filtros -->
|
||||||
<div class="mt-6 bg-white shadow sm:rounded-lg p-4">
|
<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>
|
<div>
|
||||||
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
|
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
|
||||||
<select
|
<select
|
||||||
@@ -288,12 +309,13 @@
|
|||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<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="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">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">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">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">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">
|
<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>
|
||||||
@@ -301,9 +323,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
{#if isLoading}
|
{#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}
|
{: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}
|
{:else}
|
||||||
{#each tickets as ticket}
|
{#each tickets as ticket}
|
||||||
<tr
|
<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">
|
<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)}
|
{ticket.ticket_number || ticket.id.substring(0, 8)}
|
||||||
</td>
|
</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">
|
<td class="px-3 py-4 text-sm text-gray-900">
|
||||||
<div class="font-medium">{ticket.subject}</div>
|
<div class="font-medium">{ticket.subject}</div>
|
||||||
<div class="text-gray-500 truncate max-w-xs">{ticket.description}</div>
|
<div class="text-gray-500 truncate max-w-xs">{ticket.description}</div>
|
||||||
@@ -327,8 +353,10 @@
|
|||||||
{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-4 text-sm text-gray-900">
|
||||||
{getCategoryName(ticket.category_id)}
|
<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>
|
||||||
<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">
|
||||||
{getUserName(ticket.assigned_to)}
|
{getUserName(ticket.assigned_to)}
|
||||||
|
|||||||
Reference in New Issue
Block a user