Compare commits

..

4 Commits

Author SHA1 Message Date
5cff309422 hotfix: Advanced filtering system v1.4.1.1 2026-02-10 08:49:37 -07:00
94e9d91586 🚀 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
2026-02-10 08:18:53 -07:00
c9dd024c7e 🔧 Fix critical issues and improve project stability
- Fixed TypeScript errors in tickets.ts (FastAPIValidationError interface)
- Corrected SQLAlchemy imports with TYPE_CHECKING pattern in models
- Created missing tsconfig.json files for both frontends
- Enhanced .env configuration with additional security settings
- Completed /auth/me endpoint implementation with database queries
- Fixed Alembic migrations issue (a7f9c8d2e4b1 → 13362e8c493a)
- Set up basic testing framework with pytest
- Resolved configuration issues in Pydantic Settings
- Cleaned up duplicate migrations structure
- Format improvements in tsconfig.json files

 All services healthy, tests passing (9/10), migrations working
2026-02-09 13:55:10 -07:00
a13a8cb0e8 feat: Add maintenance scripts and testing utilities
- Added password management utilities for user administration
- Added PowerShell scripts for testing attachment endpoints
- Enhanced development and testing workflow capabilities
- Completed v1.4.0 with all maintenance tools included"
2026-02-09 13:33:49 -07:00
10 changed files with 754 additions and 147 deletions

View File

@@ -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)
async def get_ticket(
ticket_id: str,

View File

@@ -0,0 +1,77 @@
"""
Script para verificar y actualizar password del test_user
"""
import asyncio
import sys
import os
from sqlalchemy import select
from passlib.context import CryptContext
# Configurar el path
backend_path = os.path.join(os.path.dirname(__file__), 'backend')
sys.path.insert(0, backend_path)
from app.core.database import AsyncSessionLocal
from app.models.user import User
# Configurar passlib igual que en security.py
pwd_context = CryptContext(
schemes=["argon2", "bcrypt"],
deprecated="auto",
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
async def check_and_fix_test_user():
"""Verificar y actualizar password del test_user"""
# Contraseñas posibles
possible_passwords = [
"admin123",
"TestPassword123!",
"password123",
"test123",
"hashed_password"
]
async with AsyncSessionLocal() as session:
try:
# Buscar test_user
result = await session.execute(
select(User).where(User.email == "test_user@example.com")
)
user = result.scalar_one_or_none()
if not user:
print("❌ Usuario test_user@example.com no encontrado")
return
print(f"✅ Usuario encontrado: {user.email}")
print(f"Hash actual: {user.password_hash}")
# Probar contraseñas posibles
found_password = None
for password in possible_passwords:
if pwd_context.verify(password, user.password_hash):
found_password = password
break
if found_password:
print(f"🎉 Contraseña encontrada: {found_password}")
else:
print("❌ Ninguna contraseña coincide")
print("Estableciendo nueva contraseña: admin123")
# Establecer nueva contraseña
new_password = "admin123"
user.password_hash = pwd_context.hash(new_password)
await session.commit()
print(f"✅ Contraseña actualizada a: {new_password}")
except Exception as e:
print(f"❌ Error: {e}")
await session.rollback()
if __name__ == "__main__":
asyncio.run(check_and_fix_test_user())

View File

@@ -0,0 +1,59 @@
"""
Script para establecer contraseña real al test_user
"""
import asyncio
import sys
import os
from sqlalchemy import select
from passlib.context import CryptContext
# Configurar el path
backend_path = os.path.join(os.path.dirname(__file__), 'backend')
sys.path.insert(0, backend_path)
from app.core.database import AsyncSessionLocal
from app.models.user import User
# Configurar passlib
pwd_context = CryptContext(
schemes=["argon2", "bcrypt"],
deprecated="auto",
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
async def set_test_user_password():
"""Establecer contraseña admin123 para test_user"""
new_password = "admin123"
password_hash = pwd_context.hash(new_password)
async with AsyncSessionLocal() as session:
try:
# Buscar test_user
result = await session.execute(
select(User).where(User.email == "test_user@example.com")
)
user = result.scalar_one_or_none()
if not user:
print("❌ Usuario test_user@example.com no encontrado")
return
print(f"✅ Usuario encontrado: {user.email}")
print(f"Hash anterior: {user.password_hash}")
# Establecer nueva contraseña hash
user.password_hash = password_hash
await session.commit()
print(f"🎉 Contraseña establecida: {new_password}")
print(f"✅ Nuevo hash: {password_hash[:50]}...")
except Exception as e:
print(f"❌ Error: {e}")
await session.rollback()
if __name__ == "__main__":
asyncio.run(set_test_user_password())

View File

@@ -14,7 +14,8 @@
let closeResolution = '';
let fileInput: HTMLInputElement;
let isUploading = false;
let pollingInterval: any = null;
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}

View File

@@ -1,29 +1,33 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowSyntheticDefaultImports": true,
"isolatedModules": true
},
"include": [
"src/**/*",
"app.d.ts"
],
"exclude": [
"node_modules/**",
".svelte-kit/**",
"build/**",
"dist/**"
]
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"target": "ES2020",
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"allowSyntheticDefaultImports": true,
"isolatedModules": true
},
"include": [
"src/**/*",
"app.d.ts"
],
"exclude": [
"node_modules/**",
".svelte-kit/**",
"build/**",
"dist/**"
]
}

View File

@@ -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);

View File

@@ -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,15 @@
// Filtros
let filterStatus = '';
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
let editFormData = {
@@ -50,25 +60,34 @@
{ 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;
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('/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 {
@@ -80,6 +99,28 @@
function applyFilters() {
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() {
selectedTicket = null;
@@ -219,12 +260,31 @@
</script>
<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">
<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 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
type="button"
on:click={openCreateModal}
@@ -235,46 +295,139 @@
</div>
</div>
<!-- Filtros -->
<div class="mt-6 bg-white shadow sm:rounded-lg p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
<select
id="filterStatus"
bind:value={filterStatus}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
<option value="">Todos</option>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
</select>
<!-- Filtros Avanzados -->
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Filtros</h3>
<!-- Primera fila - Búsqueda y Filtros principales -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4 mb-4">
<!-- Búsqueda por texto -->
<div class="sm:col-span-2">
<label for="searchText" class="block text-sm font-medium text-gray-700 mb-1">Búsqueda</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
</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>
<label for="filterPriority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<select
id="filterPriority"
bind:value={filterPriority}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
<option value="">Todas</option>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<div class="flex items-end">
<button
on:click={loadData}
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Actualizar
</button>
<!-- Segunda fila - Filtros secundarios -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-5">
<!-- Prioridad -->
<div>
<label for="filterPriority" class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
<select
id="filterPriority"
bind:value={filterPriority}
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 PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<!-- Categoría -->
<div>
<label for="filterCategory" class="block text-sm font-medium text-gray-700 mb-1">Categoría</label>
<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>
@@ -288,12 +441,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 +455,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 +467,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 +485,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)}

View File

@@ -1,29 +1,33 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowSyntheticDefaultImports": true,
"isolatedModules": true
},
"include": [
"src/**/*",
"app.d.ts"
],
"exclude": [
"node_modules/**",
".svelte-kit/**",
"build/**",
"dist/**"
]
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"target": "ES2020",
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"allowSyntheticDefaultImports": true,
"isolatedModules": true
},
"include": [
"src/**/*",
"app.d.ts"
],
"exclude": [
"node_modules/**",
".svelte-kit/**",
"build/**",
"dist/**"
]
}

34
test_attachments.ps1 Normal file
View File

@@ -0,0 +1,34 @@
# Test de Attachments API
# Ejecutar desde PowerShell
# 1. Login
$body = '{"email":"admin@aduanasoft.com","password":"admin123","tenant_slug":"aduanasoft"}'
$login = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" -Method POST -Body $body -ContentType "application/json"
$token = $login.data.access_token
$tenantId = $login.data.user.tenant_id
$headers = @{
"Authorization" = "Bearer $token"
"X-Tenant-ID" = $tenantId
}
Write-Host "Autenticacion exitosa" -ForegroundColor Green
# 2. Listar tickets
$tickets = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets" -Headers $headers
$ticketId = $tickets.data[0].id
Write-Host "Ticket ID obtenido: $ticketId" -ForegroundColor Green
# 3. Listar attachments del ticket
$attachments = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets/$ticketId/attachments" -Headers $headers
Write-Host "Attachments listados: $($attachments.Count) encontrados" -ForegroundColor Green
if ($attachments.Count -gt 0) {
$attachments | Format-Table id, original_filename, file_size, created_at
}
Write-Host ""
Write-Host "TEST COMPLETADO" -ForegroundColor Cyan

View File

@@ -0,0 +1,45 @@
# Test script para attachments endpoint
Write-Host "=== Testing Attachments Endpoint ===" -ForegroundColor Cyan
# 1. Login
$body = '{"email":"admin@aduanasoft.com","password":"admin123","tenant_slug":"aduanasoft"}'
try {
$login = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" -Method POST -Body $body -ContentType "application/json"
Write-Host "[OK] Login exitoso" -ForegroundColor Green
$token = $login.access_token
$tenantId = $login.user.tenant_id
Write-Host "Token: $($token.Substring(0,20))..." -ForegroundColor Gray
Write-Host "Tenant ID: $tenantId" -ForegroundColor Gray
# 2. Test attachments endpoint
$headers = @{
"Authorization" = "Bearer $token"
"X-Tenant-ID" = $tenantId
}
$url = "http://localhost:8000/v1/tickets/68eaf0fe-b5c3-4d42-9b36-895b439be583/attachments"
Write-Host "`nProbando GET $url" -ForegroundColor Yellow
try {
$response = Invoke-WebRequest -Uri $url -Headers $headers -Method GET
Write-Host "[OK] Status Code: $($response.StatusCode)" -ForegroundColor Green
$data = $response.Content | ConvertFrom-Json
Write-Host "[OK] Attachments encontrados: $($data.Count)" -ForegroundColor Green
if ($data.Count -gt 0) {
$data | Format-Table id, original_filename, file_size
} else {
Write-Host " (No hay attachments para este ticket)" -ForegroundColor Gray
}
} catch {
Write-Host "[ERROR] En request: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Status Code: $($_.Exception.Response.StatusCode.value__)" -ForegroundColor Red
}
} catch {
Write-Host "[ERROR] En login: $($_.Exception.Message)" -ForegroundColor Red
}