Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cff309422 | |||
| 94e9d91586 | |||
| c9dd024c7e |
@@ -210,6 +210,205 @@ 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,
|
||||||
|
category_filter: Optional[str] = None,
|
||||||
|
assigned_to_filter: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: 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
|
||||||
|
Filtros: estado, prioridad, tenant, categoría, asignado a, búsqueda de texto y fechas
|
||||||
|
"""
|
||||||
|
# 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
if category_filter:
|
||||||
|
try:
|
||||||
|
category_uuid = uuid.UUID(category_filter)
|
||||||
|
query = query.where(Ticket.category_id == category_uuid)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid category ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
if assigned_to_filter:
|
||||||
|
try:
|
||||||
|
assigned_uuid = uuid.UUID(assigned_to_filter)
|
||||||
|
query = query.where(Ticket.assigned_to == assigned_uuid)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid assigned user ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
# Búsqueda de texto en subject y description
|
||||||
|
search_pattern = f"%{search}%"
|
||||||
|
query = query.where(
|
||||||
|
(Ticket.subject.ilike(search_pattern)) |
|
||||||
|
(Ticket.description.ilike(search_pattern))
|
||||||
|
)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
date_from_parsed = datetime.fromisoformat(date_from)
|
||||||
|
query = query.where(Ticket.created_at >= date_from_parsed)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid date_from format. Use YYYY-MM-DD"
|
||||||
|
)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
# Agregar 1 día para incluir todo el día final
|
||||||
|
date_to_parsed = datetime.fromisoformat(date_to) + timedelta(days=1)
|
||||||
|
query = query.where(Ticket.created_at < date_to_parsed)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid date_to format. Use YYYY-MM-DD"
|
||||||
|
)
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
{
|
{
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": [
|
||||||
"allowSyntheticDefaultImports": true,
|
"ES2020",
|
||||||
"isolatedModules": true
|
"DOM",
|
||||||
},
|
"DOM.Iterable"
|
||||||
"include": [
|
],
|
||||||
"src/**/*",
|
"allowSyntheticDefaultImports": true,
|
||||||
"app.d.ts"
|
"isolatedModules": true
|
||||||
],
|
},
|
||||||
"exclude": [
|
"include": [
|
||||||
"node_modules/**",
|
"src/**/*",
|
||||||
".svelte-kit/**",
|
"app.d.ts"
|
||||||
"build/**",
|
],
|
||||||
"dist/**"
|
"exclude": [
|
||||||
]
|
"node_modules/**",
|
||||||
|
".svelte-kit/**",
|
||||||
|
"build/**",
|
||||||
|
"dist/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -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,15 @@
|
|||||||
// Filtros
|
// Filtros
|
||||||
let filterStatus = '';
|
let filterStatus = '';
|
||||||
let filterPriority = '';
|
let filterPriority = '';
|
||||||
|
let filterTenant = ''; // Nuevo filtro por cliente/tenant
|
||||||
|
let filterCategory = ''; // Filtro por categoría
|
||||||
|
let filterAssignedTo = ''; // Filtro por asignado a
|
||||||
|
let searchText = ''; // Búsqueda por texto
|
||||||
|
let filterDateFrom = ''; // Fecha desde
|
||||||
|
let filterDateTo = ''; // Fecha hasta
|
||||||
|
|
||||||
|
// Estado de filtros
|
||||||
|
$: activeFiltersCount = [filterTenant, filterStatus, filterPriority, filterCategory, filterAssignedTo, searchText, filterDateFrom, filterDateTo].filter(f => f && f.trim()).length;
|
||||||
|
|
||||||
// Form para editar
|
// Form para editar
|
||||||
let editFormData = {
|
let editFormData = {
|
||||||
@@ -50,25 +60,34 @@
|
|||||||
{ 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;
|
||||||
}
|
if (filterCategory) ticketParams.category_filter = filterCategory;
|
||||||
}),
|
if (filterAssignedTo) ticketParams.assigned_to_filter = filterAssignedTo;
|
||||||
|
if (searchText) ticketParams.search = searchText;
|
||||||
|
if (filterDateFrom) ticketParams.date_from = filterDateFrom;
|
||||||
|
if (filterDateTo) ticketParams.date_to = filterDateTo;
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -80,6 +99,28 @@
|
|||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limpiar todos los filtros
|
||||||
|
function clearFilters() {
|
||||||
|
filterStatus = '';
|
||||||
|
filterPriority = '';
|
||||||
|
filterTenant = '';
|
||||||
|
filterCategory = '';
|
||||||
|
filterAssignedTo = '';
|
||||||
|
searchText = '';
|
||||||
|
filterDateFrom = '';
|
||||||
|
filterDateTo = '';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Búsqueda en tiempo real (debounced)
|
||||||
|
let searchTimeout;
|
||||||
|
function handleSearchInput() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
applyFilters();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
selectedTicket = null;
|
selectedTicket = null;
|
||||||
@@ -219,12 +260,31 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
<div class="px-4 py-8 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 sm:justify-between">
|
||||||
<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-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>
|
<p class="mt-2 text-sm text-gray-700">
|
||||||
|
Gestión de tickets del sistema de mesa de ayuda.
|
||||||
|
{#if activeFiltersCount > 0}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">
|
||||||
|
{activeFiltersCount} filtro{activeFiltersCount !== 1 ? 's' : ''} activo{activeFiltersCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</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 space-x-3">
|
||||||
|
{#if activeFiltersCount > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={clearFilters}
|
||||||
|
class="inline-flex items-center justify-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={openCreateModal}
|
on:click={openCreateModal}
|
||||||
@@ -235,46 +295,139 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtros -->
|
<!-- Filtros Avanzados -->
|
||||||
<div class="mt-6 bg-white shadow sm:rounded-lg p-4">
|
<div class="mt-6 bg-white shadow sm:rounded-lg">
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<div>
|
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Filtros</h3>
|
||||||
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
|
|
||||||
<select
|
<!-- Primera fila - Búsqueda y Filtros principales -->
|
||||||
id="filterStatus"
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4 mb-4">
|
||||||
bind:value={filterStatus}
|
<!-- Búsqueda por texto -->
|
||||||
on:change={applyFilters}
|
<div class="sm:col-span-2">
|
||||||
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"
|
<label for="searchText" class="block text-sm font-medium text-gray-700 mb-1">Búsqueda</label>
|
||||||
>
|
<div class="relative">
|
||||||
<option value="">Todos</option>
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
{#each STATUSES as status}
|
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<option value={status.value}>{status.label}</option>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
{/each}
|
</svg>
|
||||||
</select>
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="searchText"
|
||||||
|
bind:value={searchText}
|
||||||
|
on:input={handleSearchInput}
|
||||||
|
placeholder="Buscar en título o descripción..."
|
||||||
|
class="pl-10 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cliente/Empresa -->
|
||||||
|
<div>
|
||||||
|
<label for="filterTenant" class="block text-sm font-medium text-gray-700 mb-1">Cliente/Empresa</label>
|
||||||
|
<select
|
||||||
|
id="filterTenant"
|
||||||
|
bind:value={filterTenant}
|
||||||
|
on:change={applyFilters}
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todos los clientes</option>
|
||||||
|
{#each tenants as tenant}
|
||||||
|
<option value={tenant.id}>{tenant.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estado -->
|
||||||
|
<div>
|
||||||
|
<label for="filterStatus" class="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
||||||
|
<select
|
||||||
|
id="filterStatus"
|
||||||
|
bind:value={filterStatus}
|
||||||
|
on:change={applyFilters}
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{#each STATUSES as status}
|
||||||
|
<option value={status.value}>{status.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Segunda fila - Filtros secundarios -->
|
||||||
<label for="filterPriority" class="block text-sm font-medium text-gray-700">Prioridad</label>
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-5">
|
||||||
<select
|
<!-- Prioridad -->
|
||||||
id="filterPriority"
|
<div>
|
||||||
bind:value={filterPriority}
|
<label for="filterPriority" class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
|
||||||
on:change={applyFilters}
|
<select
|
||||||
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"
|
id="filterPriority"
|
||||||
>
|
bind:value={filterPriority}
|
||||||
<option value="">Todas</option>
|
on:change={applyFilters}
|
||||||
{#each PRIORITIES as priority}
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
<option value={priority.value}>{priority.label}</option>
|
>
|
||||||
{/each}
|
<option value="">Todas</option>
|
||||||
</select>
|
{#each PRIORITIES as priority}
|
||||||
</div>
|
<option value={priority.value}>{priority.label}</option>
|
||||||
|
{/each}
|
||||||
<div class="flex items-end">
|
</select>
|
||||||
<button
|
</div>
|
||||||
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"
|
<!-- Categoría -->
|
||||||
>
|
<div>
|
||||||
Actualizar
|
<label for="filterCategory" class="block text-sm font-medium text-gray-700 mb-1">Categoría</label>
|
||||||
</button>
|
<select
|
||||||
|
id="filterCategory"
|
||||||
|
bind:value={filterCategory}
|
||||||
|
on:change={applyFilters}
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{#each categories as category}
|
||||||
|
<option value={category.id}>{category.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Asignado a -->
|
||||||
|
<div>
|
||||||
|
<label for="filterAssignedTo" class="block text-sm font-medium text-gray-700 mb-1">Asignado a</label>
|
||||||
|
<select
|
||||||
|
id="filterAssignedTo"
|
||||||
|
bind:value={filterAssignedTo}
|
||||||
|
on:change={applyFilters}
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{#each users.filter(u => u.role === 'AGENT' || u.role === 'SUPPORT_MANAGER' || u.role === 'ADMIN') as user}
|
||||||
|
<option value={user.id}>{user.first_name} {user.last_name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fecha desde -->
|
||||||
|
<div>
|
||||||
|
<label for="filterDateFrom" class="block text-sm font-medium text-gray-700 mb-1">Desde</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="filterDateFrom"
|
||||||
|
bind:value={filterDateFrom}
|
||||||
|
on:change={applyFilters}
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fecha hasta -->
|
||||||
|
<div>
|
||||||
|
<label for="filterDateTo" class="block text-sm font-medium text-gray-700 mb-1">Hasta</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="filterDateTo"
|
||||||
|
bind:value={filterDateTo}
|
||||||
|
on:change={applyFilters}
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,12 +441,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 +455,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 +467,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 +485,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)}
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
{
|
{
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": [
|
||||||
"allowSyntheticDefaultImports": true,
|
"ES2020",
|
||||||
"isolatedModules": true
|
"DOM",
|
||||||
},
|
"DOM.Iterable"
|
||||||
"include": [
|
],
|
||||||
"src/**/*",
|
"allowSyntheticDefaultImports": true,
|
||||||
"app.d.ts"
|
"isolatedModules": true
|
||||||
],
|
},
|
||||||
"exclude": [
|
"include": [
|
||||||
"node_modules/**",
|
"src/**/*",
|
||||||
".svelte-kit/**",
|
"app.d.ts"
|
||||||
"build/**",
|
],
|
||||||
"dist/**"
|
"exclude": [
|
||||||
]
|
"node_modules/**",
|
||||||
|
".svelte-kit/**",
|
||||||
|
"build/**",
|
||||||
|
"dist/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user