feat: Version 1.11.0 - Mejoras en auditoría, SLA, frontend y correcciones de sincronización

- Refactorización de endpoints de auditoría y helpers
- Mejoras en esquemas de auditoría (audit.py)
- Correcciones en endpoint SLA
- Actualizaciones en múltiples rutas del frontend interno:
  layout, tickets, usuarios, tenants, categorías, sistemas,
  SLA (at-risk, violations), auditoría (main + security), login, perfil
- Actualización de tailwind.config.js
- Eliminación de docs de versiones anteriores (CAMBIOS_v1.10.0, v1.8.0, OPTIMIZACIONES)
- Nuevos scripts de prueba: generate_security_test_data.py, generate_sla_test_data.py
- Script de prueba de sincronización crítica (test_critical_sync.ps1)
- README actualizado en scripts/
This commit is contained in:
2026-02-20 10:53:53 -07:00
parent 517297e89a
commit ceea67eb2b
32 changed files with 5715 additions and 3324 deletions

View File

@@ -50,4 +50,4 @@
on:dismiss={() => toast.dismiss(toastMessage.id)}
/>
{/each}
</div>
</div>

View File

@@ -16,28 +16,28 @@
description: 'Gestión de organizaciones y tenants',
icon: 'users',
href: '/tenants',
color: 'bg-blue-500'
color: 'bg-blue-600'
},
{
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'user-plus',
href: '/users',
color: 'bg-green-500'
color: 'bg-green-600'
},
{
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'server',
href: '/systems',
color: 'bg-purple-500'
color: 'bg-gray-700'
},
{
title: 'Categorías',
description: 'Clasificación de tickets',
icon: 'tag',
href: '/categories',
color: 'bg-orange-500'
color: 'bg-orange-600'
}
];
</script>
@@ -62,32 +62,20 @@
{#each cards as card}
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="{card.color} rounded-md p-3">
<!-- Simple SVG Icon placeholder since Icon component might expect specific names that map to SVGs -->
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{card.title}
</dt>
<dd>
<div class="text-xs text-gray-900 font-light mt-1">
{card.description}
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{card.title}
</dt>
<dd>
<div class="text-xs text-gray-900 font-light mt-1">
{card.description}
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="font-medium text-cyan-700 hover:text-cyan-900">
<span class="font-medium text-blue-700 hover:text-blue-900">
Ver detalles
</span>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,19 +7,61 @@
// Estado
let isLoading = false;
let analysis = null;
let analysis: any = null;
let analysisHours = 24;
let selectedThreat = null;
let selectedThreat: any = null;
let showActionModal = false;
let actionType = '';
let actionTarget = '';
let actionReason = '';
let actionDuration = 60;
// Filtros y búsqueda
let activeTab: 'all' | 'critical' | 'high' | 'medium' | 'low' | 'resolved' = 'all';
let searchQuery = '';
let filterType = '';
let showFilters = false;
let selectedThreats = new Set<string>();
// Estado de amenazas resueltas (simulado - idealmente vendría del backend)
let resolvedThreats = new Set<string>();
// Usuario actual
$: currentUser = $auth.user;
$: canExecuteActions = currentUser && (currentUser.role === 'ADMIN' || currentUser.role === 'SUPPORT_MANAGER');
// Amenazas filtradas
$: filteredThreats = analysis?.threats?.filter((threat: any) => {
const matchesTab =
activeTab === 'all' ? !resolvedThreats.has(threat.id) :
activeTab === 'resolved' ? resolvedThreats.has(threat.id) :
(threat.severity === activeTab && !resolvedThreats.has(threat.id));
const matchesSearch = !searchQuery ||
threat.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
threat.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
threat.affected_ips.some((ip: string) => ip.includes(searchQuery)) ||
threat.affected_users.some((user: string) => user.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesType = !filterType || threat.type === filterType;
return matchesTab && matchesSearch && matchesType;
}) || [];
// Contadores por tab
$: tabCounts = {
all: analysis?.threats?.filter((t: any) => !resolvedThreats.has(t.id)).length || 0,
critical: analysis?.threats?.filter((t: any) => t.severity === 'critical' && !resolvedThreats.has(t.id)).length || 0,
high: analysis?.threats?.filter((t: any) => t.severity === 'high' && !resolvedThreats.has(t.id)).length || 0,
medium: analysis?.threats?.filter((t: any) => t.severity === 'medium' && !resolvedThreats.has(t.id)).length || 0,
low: analysis?.threats?.filter((t: any) => t.severity === 'low' && !resolvedThreats.has(t.id)).length || 0,
resolved: resolvedThreats.size
};
// Tipos únicos de amenazas
$: threatTypes = analysis?.threats ?
[...new Set(analysis.threats.map((t: any) => t.type))] : [];
/**
* Cargar análisis de seguridad
*/
@@ -44,32 +86,77 @@
}
/**
* Obtener color según nivel de riesgo
* Obtener color según nivel de riesgo (neutral)
*/
function getRiskColor(level: string) {
const colors: any = {
safe: 'bg-green-100 text-green-800 border-green-300',
low: 'bg-blue-100 text-blue-800 border-blue-300',
medium: 'bg-yellow-100 text-yellow-800 border-yellow-300',
high: 'bg-orange-100 text-orange-800 border-orange-300',
critical: 'bg-red-100 text-red-800 border-red-300'
safe: 'bg-gray-50 text-green-700 border border-green-200',
low: 'bg-gray-50 text-blue-700 border border-blue-200',
medium: 'bg-gray-50 text-yellow-800 border border-yellow-200',
high: 'bg-gray-50 text-orange-700 border border-orange-200',
critical: 'bg-gray-50 text-red-700 border border-red-200'
};
return colors[level] || colors.low;
}
/**
* Obtener color de severidad de amenaza
* Obtener color de severidad de amenaza (neutral)
*/
function getSeverityColor(severity: string) {
const colors: any = {
low: 'bg-blue-600 text-white',
medium: 'bg-yellow-500 text-white',
high: 'bg-orange-600 text-white',
critical: 'bg-red-600 text-white'
low: 'bg-gray-50 text-blue-700 border border-blue-200',
medium: 'bg-gray-50 text-yellow-800 border border-yellow-200',
high: 'bg-gray-50 text-orange-700 border border-orange-200',
critical: 'bg-gray-50 text-red-700 border border-red-200'
};
return colors[severity] || colors.low;
}
/**
* Marcar amenaza como resuelta
*/
function toggleThreatResolved(threatId: string) {
if (resolvedThreats.has(threatId)) {
resolvedThreats.delete(threatId);
} else {
resolvedThreats.add(threatId);
}
resolvedThreats = resolvedThreats; // Trigger reactivity
toast.success(resolvedThreats.has(threatId) ? 'Amenaza marcada como resuelta' : 'Amenaza marcada como activa');
}
/**
* Seleccionar/deseleccionar amenaza
*/
function toggleThreatSelection(threatId: string) {
if (selectedThreats.has(threatId)) {
selectedThreats.delete(threatId);
} else {
selectedThreats.add(threatId);
}
selectedThreats = selectedThreats;
}
/**
* Resolver amenazas en lote
*/
function resolveSelectedThreats() {
selectedThreats.forEach(id => resolvedThreats.add(id));
resolvedThreats = resolvedThreats;
selectedThreats.clear();
selectedThreats = selectedThreats;
toast.success(`${resolvedThreats.size} amenazas resueltas`);
}
/**
* Limpiar filtros
*/
function clearFilters() {
searchQuery = '';
filterType = '';
activeTab = 'all';
}
/**
* Obtener icono de tipo de amenaza
*/
@@ -174,130 +261,140 @@
</script>
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<!-- Header -->
<!-- Header con Breadcrumb -->
<div class="mb-6">
<nav class="flex mb-3" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2">
<li>
<a href="/audit" class="text-gray-500 hover:text-gray-700 text-sm">Auditoría</a>
</li>
<li class="flex items-center">
<span class="text-gray-400 mx-2">/</span>
<span class="text-sm font-medium text-gray-900">Análisis de Seguridad</span>
</li>
</ol>
</nav>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Análisis de Seguridad
</h1>
<p class="mt-1 text-sm text-gray-500">
Detección de amenazas y análisis de vulnerabilidades
</p>
<div class="flex items-center gap-3">
<div>
<h1 class="text-2xl font-bold text-gray-900">Análisis de Seguridad</h1>
<p class="text-sm text-gray-500">Detección de amenazas y gestión de incidentes</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
on:click={loadSecurityAnalysis}
class="px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800 transition-colors"
>
Actualizar
</button>
</div>
<button
on:click={() => loadSecurityAnalysis()}
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualizar
</button>
</div>
</div>
<!-- Selector de Período -->
<div class="bg-white shadow rounded-lg p-4 mb-6">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium text-gray-700">Período de Análisis</span>
<div class="bg-white shadow-sm rounded-lg p-4 mb-6 border border-gray-200">
<div class="flex items-center gap-2 mb-3">
<span class="text-sm font-semibold text-gray-700">Período de Análisis</span>
</div>
{#if analysis}
<span class="text-xs text-gray-500">
Última actualización: {formatDate(analysis.generated_at)}
</span>
{/if}
</div>
<div class="flex flex-wrap gap-2">
<button
on:click={() => changeAnalysisPeriod(24)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
>
Últimas 24 horas
Últimas 24h
</button>
<button
on:click={() => changeAnalysisPeriod(48)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
>
Últimas 48 horas
Últimas 48h
</button>
<button
on:click={() => changeAnalysisPeriod(168)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
>
Última semana
</button>
<button
on:click={() => changeAnalysisPeriod(720)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 720 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
>
Último mes
</button>
</div>
</div>
{#if isLoading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-600"></div>
<div class="flex justify-center items-center py-20">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-700 mx-auto mb-4"></div>
<p class="text-sm text-gray-500">Analizando seguridad...</p>
</div>
</div>
{:else if analysis}
<!-- Resumen de Riesgo -->
<div class="bg-white shadow rounded-lg p-6 mb-6 border-l-4 {getRiskColor(analysis.overall_risk_level)}">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">Nivel de Riesgo General</h3>
<p class="text-sm text-gray-600 mt-1">Análisis de {analysis.analysis_period_hours} horas</p>
<!-- Dashboard KPIs -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<!-- Nivel de Riesgo -->
<div class="bg-white rounded-lg shadow-sm p-5 border border-gray-200 lg:col-span-1">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-gray-600 uppercase">Nivel de Riesgo</span>
</div>
<div class="text-right">
<span class="inline-block px-4 py-2 text-2xl font-bold rounded-lg {getRiskColor(analysis.overall_risk_level)}">
<div class="mt-2">
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-bold {getRiskColor(analysis.overall_risk_level)}">
{analysis.overall_risk_level.toUpperCase()}
</span>
<p class="text-xs text-gray-500 mt-1">Generado: {formatDate(analysis.generated_at)}</p>
</div>
<p class="text-xs text-gray-500 mt-2">{analysis.analysis_period_hours}h análisis</p>
</div>
</div>
<!-- Estadísticas Rápidas -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4">
<!-- Amenazas Detectadas -->
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-red-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Amenazas Detectadas</p>
<p class="text-2xl font-bold text-red-600">{analysis.total_threats_detected}</p>
<div class="flex-1">
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Amenazas</p>
<p class="text-2xl font-bold text-gray-900">{analysis.total_threats_detected}</p>
<p class="text-xs text-gray-500 mt-1">Detectadas</p>
</div>
<svg class="w-10 h-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<!-- Intentos Fallidos -->
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-orange-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Intentos Fallidos</p>
<p class="text-2xl font-bold text-orange-600">{analysis.failed_login_attempts}</p>
<div class="flex-1">
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Intentos Fallidos</p>
<p class="text-2xl font-bold text-gray-900">{analysis.failed_login_attempts}</p>
<p class="text-xs text-gray-500 mt-1">Logins rechazados</p>
</div>
<svg class="w-10 h-10 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<!-- IPs Sospechosas -->
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-yellow-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">IPs Sospechosas</p>
<p class="text-2xl font-bold text-yellow-600">{analysis.suspicious_ips_count}</p>
<div class="flex-1">
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">IPs Sospechosas</p>
<p class="text-2xl font-bold text-gray-900">{analysis.suspicious_ips_count}</p>
<p class="text-xs text-gray-500 mt-1">En seguimiento</p>
</div>
<svg class="w-10 h-10 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<!-- Acciones Críticas -->
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-blue-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Acciones Críticas</p>
<p class="text-2xl font-bold text-red-600">{analysis.critical_actions_count}</p>
<div class="flex-1">
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Acciones Críticas</p>
<p class="text-2xl font-bold text-gray-900">{analysis.critical_actions_count}</p>
<p class="text-xs text-gray-500 mt-1">Registradas</p>
</div>
<svg class="w-10 h-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
</div>
</div>
@@ -305,162 +402,335 @@
<!-- Recomendaciones Generales -->
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
<ul class="space-y-1">
{#each analysis.recommended_actions as action}
<li class="text-sm text-blue-800 flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{action}
</li>
{/each}
</ul>
</div>
<div>
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
<ul class="space-y-1">
{#each analysis.recommended_actions as action}
<li class="text-sm text-blue-800">{action}</li>
{/each}
</ul>
</div>
</div>
{/if}
<!-- Tabs y Filtros -->
<div class="bg-white shadow-sm rounded-lg mb-6 border border-gray-200 overflow-hidden">
<!-- Tabs -->
<div class="border-b border-gray-200 bg-gray-50">
<div class="flex overflow-x-auto">
<button
on:click={() => activeTab = 'all'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'all' ? 'border-blue-700 text-blue-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Todas
{#if tabCounts.all > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'all' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.all}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'critical'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'critical' ? 'border-red-600 text-red-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Críticas
{#if tabCounts.critical > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'critical' ? 'bg-red-100 text-red-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.critical}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'high'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'high' ? 'border-orange-500 text-orange-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Altas
{#if tabCounts.high > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.high}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'medium'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'medium' ? 'border-yellow-500 text-yellow-800 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Medias
{#if tabCounts.medium > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-200 text-gray-700'}">
{tabCounts.medium}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'low'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'low' ? 'border-blue-500 text-blue-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Bajas
{#if tabCounts.low > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'low' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.low}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'resolved'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'resolved' ? 'border-green-500 text-green-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Resueltas
{#if tabCounts.resolved > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'resolved' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.resolved}
</span>
{/if}
</button>
</div>
</div>
<!-- Barra de Búsqueda y Filtros -->
<div class="p-4 bg-white">
<div class="flex flex-col md:flex-row gap-3">
<!-- Búsqueda -->
<div class="flex-1 relative">
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar por descripción, IP, usuario..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<!-- Filtro por Tipo -->
<select
bind:value={filterType}
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
>
<option value="">Todos los tipos</option>
{#each threatTypes as type}
<option value={type}>{getThreatTypeText(type)}</option>
{/each}
</select>
<!-- Limpiar -->
{#if searchQuery || filterType}
<button
on:click={clearFilters}
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900"
>
Limpiar
</button>
{/if}
</div>
<!-- Acciones en Lote -->
{#if selectedThreats.size > 0 && canExecuteActions}
<div class="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
<span class="text-sm font-medium text-blue-900">
{selectedThreats.size} amenaza{selectedThreats.size > 1 ? 's' : ''} seleccionada{selectedThreats.size > 1 ? 's' : ''}
</span>
<div class="flex gap-2">
<button
on:click={resolveSelectedThreats}
class="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 font-medium"
>
Resolver
</button>
<button
on:click={() => { selectedThreats.clear(); selectedThreats = selectedThreats; }}
class="px-3 py-1.5 bg-white border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50"
>
Cancelar
</button>
</div>
</div>
{/if}
</div>
</div>
<!-- Lista de Amenazas -->
{#if analysis.threats && analysis.threats.length > 0}
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3>
{#each analysis.threats as threat}
<div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-gray-900' : threat.severity === 'high' ? 'border-gray-600' : threat.severity === 'medium' ? 'border-gray-400' : 'border-gray-200'}">
<!-- Header de Amenaza -->
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-gray-100' : threat.severity === 'high' ? 'bg-gray-100' : threat.severity === 'medium' ? 'bg-gray-100' : 'bg-gray-50'}">
<svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-gray-900' : threat.severity === 'high' ? 'text-gray-700' : threat.severity === 'medium' ? 'text-gray-600' : 'text-gray-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} />
</svg>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-lg font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
<span class="px-2 py-1 text-xs font-semibold rounded-full {getSeverityColor(threat.severity)}">
{threat.severity.toUpperCase()}
</span>
{#if filteredThreats.length > 0}
<div class="space-y-3">
{#each filteredThreats as threat}
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden {resolvedThreats.has(threat.id) ? 'opacity-60' : ''}">
<!-- Header Compacto -->
<div class="p-4">
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3 flex-1">
<!-- Checkbox de Selección -->
{#if canExecuteActions && !resolvedThreats.has(threat.id)}
<input
type="checkbox"
checked={selectedThreats.has(threat.id)}
on:change={() => toggleThreatSelection(threat.id)}
class="mt-1 h-4 w-4 text-blue-700 border-gray-300 rounded focus:ring-blue-500"
/>
{/if}
<!-- Icono -->
<!-- Información Principal -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<h4 class="text-base font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
<span class="px-2 py-0.5 text-xs font-semibold rounded {getSeverityColor(threat.severity)}">
{threat.severity.toUpperCase()}
</span>
{#if resolvedThreats.has(threat.id)}
<span class="px-2 py-0.5 text-xs font-semibold rounded bg-gray-50 text-green-700 border border-green-200">
RESUELTA
</span>
{/if}
</div>
<p class="text-sm text-gray-700 mb-2">{threat.description}</p>
<!-- Stats Rápidos -->
<div class="flex flex-wrap gap-4 text-xs text-gray-600">
<span><strong>{threat.occurrences}</strong> ocurrencias</span>
{#if threat.affected_ips.length > 0}
<span><strong>{threat.affected_ips.length}</strong> IPs</span>
{/if}
{#if threat.affected_users.length > 0}
<span><strong>{threat.affected_users.length}</strong> usuarios</span>
{/if}
<span class="text-gray-500">|</span>
<span>{formatDate(threat.last_seen)}</span>
</div>
<!-- IPs y Usuarios (Collapsibles) -->
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
<details class="mt-3 group">
<summary class="cursor-pointer text-xs font-medium text-blue-700 hover:text-blue-800">
Ver detalles afectados
</summary>
<div class="mt-2 pl-5 space-y-2">
{#if threat.affected_ips.length > 0}
<div>
<span class="text-xs font-medium text-gray-600">IPs:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_ips as ip}
<code class="px-2 py-0.5 bg-gray-100 rounded text-xs font-mono">{ip}</code>
{/each}
</div>
</div>
{/if}
{#if threat.affected_users.length > 0}
<div>
<span class="text-xs font-medium text-gray-600">Usuarios:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_users as user}
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">{user}</span>
{/each}
</div>
</div>
{/if}
</div>
</details>
{/if}
<!-- Recomendaciones (Collapsibles) -->
{#if threat.recommendations && threat.recommendations.length > 0}
<details class="mt-2 group">
<summary class="cursor-pointer text-xs font-medium text-blue-700 hover:text-blue-800">
Ver recomendaciones
</summary>
<div class="mt-2 pl-5">
<ul class="space-y-1">
{#each threat.recommendations as rec}
<li class="text-xs text-gray-600">
{rec}
</li>
{/each}
</ul>
</div>
</details>
{/if}
</div>
<p class="text-sm text-gray-700">{threat.description}</p>
</div>
<!-- Acciones Rápidas -->
{#if canExecuteActions}
<div class="flex flex-col gap-2 flex-shrink-0">
{#if !resolvedThreats.has(threat.id)}
<button
on:click={() => toggleThreatResolved(threat.id)}
class="px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 font-medium whitespace-nowrap"
title="Marcar como resuelta"
>
Resolver
</button>
{:else}
<button
on:click={() => toggleThreatResolved(threat.id)}
class="px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded-lg hover:bg-gray-300 font-medium whitespace-nowrap"
title="Marcar como activa"
>
Reoprir
</button>
{/if}
{#if !resolvedThreats.has(threat.id)}
<div class="relative group/actions">
<button class="px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-lg hover:bg-gray-200 font-medium whitespace-nowrap">
Acciones
</button>
<div class="hidden group-hover/actions:block absolute right-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
{#if threat.affected_ips.length > 0}
<button
on:click={() => openActionModal(threat, 'block_ip')}
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Bloquear IP
</button>
{/if}
{#if threat.affected_users.length > 0}
<button
on:click={() => openActionModal(threat, 'force_password_reset')}
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Resetear Contraseña
</button>
{/if}
<button
on:click={() => openActionModal(threat, 'notify_admin')}
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Notificar Admin
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Detalles -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4 text-sm">
<div>
<span class="font-medium text-gray-600">Ocurrencias:</span>
<span class="ml-2 text-gray-900 font-semibold">{threat.occurrences}</span>
</div>
<div>
<span class="font-medium text-gray-600">Primera detección:</span>
<span class="ml-2 text-gray-900">{formatDate(threat.first_seen)}</span>
</div>
<div>
<span class="font-medium text-gray-600">Última detección:</span>
<span class="ml-2 text-gray-900">{formatDate(threat.last_seen)}</span>
</div>
</div>
<!-- IPs y Usuarios Afectados -->
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
<div class="mb-4 text-sm">
{#if threat.affected_ips.length > 0}
<div class="mb-2">
<span class="font-medium text-gray-600">IPs involucradas:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_ips as ip}
<code class="px-2 py-1 bg-gray-100 rounded text-xs font-mono">{ip}</code>
{/each}
</div>
</div>
{/if}
{#if threat.affected_users.length > 0}
<div>
<span class="font-medium text-gray-600">Usuarios afectados:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_users as user}
<span class="px-2 py-1 bg-gray-100 rounded text-xs">{user}</span>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Recomendaciones -->
{#if threat.recommendations && threat.recommendations.length > 0}
<div class="bg-gray-50 rounded-lg p-3 mb-4">
<p class="text-xs font-semibold text-gray-700 mb-2">Recomendaciones:</p>
<ul class="space-y-1">
{#each threat.recommendations as rec}
<li class="text-xs text-gray-600 flex items-start gap-2">
<svg class="w-3 h-3 text-gray-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{rec}
</li>
{/each}
</ul>
</div>
{/if}
<!-- Acciones -->
{#if canExecuteActions}
<div class="flex flex-wrap gap-2">
{#if threat.affected_ips.length > 0}
<button
on:click={() => openActionModal(threat, 'block_ip')}
class="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center gap-1"
>
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Bloquear IP
</button>
{/if}
{#if threat.affected_users.length > 0}
<button
on:click={() => openActionModal(threat, 'force_password_reset')}
class="px-3 py-1.5 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 flex items-center gap-1"
>
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Resetear Contraseña
</button>
{/if}
<button
on:click={() => openActionModal(threat, 'notify_admin')}
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-1"
>
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
Notificar Admin
</button>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<!-- No hay amenazas -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<svg class="w-16 h-16 text-gray-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sistema Seguro</h3>
<p class="text-sm text-gray-600">No se detectaron amenazas en el período analizado</p>
<!-- No hay amenazas filtradas -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-12 text-center">
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{activeTab === 'resolved' ? 'No hay amenazas resueltas' : searchQuery || filterType ? 'No se encontraron resultados' : 'Sistema Seguro'}
</h3>
<p class="text-sm text-gray-600">
{activeTab === 'resolved' ? 'No has resuelto ninguna amenaza aún.' : searchQuery || filterType ? 'Intenta ajustar los filtros de búsqueda.' : 'No se detectaron amenazas en este período.'}
</p>
{#if searchQuery || filterType}
<button
on:click={clearFilters}
class="mt-4 px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800"
>
Limpiar Filtros
</button>
{/if}
</div>
{/if}
{:else}
<!-- Estado vacío inicial -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-12 text-center">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sin Datos de Análisis</h3>
<p class="text-sm text-gray-600">Carga el análisis de seguridad para ver amenazas detectadas.</p>
</div>
{/if}
</div>
@@ -522,7 +792,7 @@
</button>
<button
on:click={executeSecurityAction}
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-md hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Ejecutar Acción
</button>

View File

@@ -102,7 +102,7 @@
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
>
Nueva Categoría
</button>
@@ -145,27 +145,27 @@
</td>
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{category.description || '-'}</td>
<td class="px-3 py-4 text-sm text-center">
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
⏱️ {category.sla_response_hours || 24}h
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 border border-blue-200">
{category.sla_response_hours || 24}h
</span>
</td>
<td class="px-3 py-4 text-sm text-center">
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
{category.sla_resolution_hours || 72}h
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
{category.sla_resolution_hours || 72}h
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-blue-100={!category.tenant_id} class:text-blue-800={!category.tenant_id} class:bg-gray-100={category.tenant_id} class:text-gray-800={category.tenant_id} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {!category.tenant_id ? 'bg-gray-100 text-blue-700 border-blue-200' : 'bg-gray-100 text-gray-700 border-gray-200'}">
{getTenantName(category.tenant_id)}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={category.is_active} class:text-green-800={category.is_active} class:bg-red-100={!category.is_active} class:text-red-800={!category.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {category.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{category.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(category)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
<button on:click={() => openEditModal(category)} class="text-blue-700 hover:text-blue-900">Editar</button>
</td>
</tr>
{/each}
@@ -182,18 +182,18 @@
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre *</label>
<input type="text" id="name" bind:value={formData.name} required 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">
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
<textarea id="description" bind:value={formData.description} rows="3" 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"></textarea>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="color" class="block text-sm font-medium text-gray-700">Color</label>
<input type="color" id="color" bind:value={formData.color} class="mt-1 block w-full h-10 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border">
<input type="color" id="color" bind:value={formData.color} class="mt-1 block w-full h-10 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border">
</div>
</div>
@@ -212,7 +212,7 @@
min="1"
max="168"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
>
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para primera respuesta</p>
</div>
@@ -228,7 +228,7 @@
min="1"
max="720"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
>
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para resolver el ticket</p>
</div>
@@ -247,7 +247,7 @@
<div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - Específico para un cliente)</label>
<select id="tenant" bind:value={formData.tenant_id} 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">
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<option value="">-- Global (Para todos) --</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
@@ -256,15 +256,15 @@
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>

View File

@@ -109,7 +109,7 @@
<div class="max-w-sm mx-auto w-full">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Identifíquese</h2>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
</div>
@@ -208,4 +208,4 @@
</div>
</div>
</div>
</div>
</div>

View File

@@ -179,7 +179,7 @@
{$auth.user?.first_name} {$auth.user?.last_name}
</p>
<p class="text-sm text-gray-500">{$auth.user?.email}</p>
<span class="inline-block mt-1 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
<span class="inline-block mt-1 px-2 py-0.5 text-xs font-medium bg-gray-100 text-blue-700 rounded-full border border-blue-200">
{roleLabel($auth.user?.role)}
</span>
</div>
@@ -197,7 +197,7 @@
<div>
<dt class="text-gray-500 font-medium">Estado</dt>
<dd class="mt-0.5">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {$auth.user?.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border {$auth.user?.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{$auth.user?.is_active ? 'Activo' : 'Inactivo'}
</span>
</dd>
@@ -219,10 +219,8 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{#if $auth.user?.is_two_factor_enabled}
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center border border-green-200">
<span class="text-xs font-bold text-green-600">2FA</span>
</div>
<div>
<p class="text-sm font-medium text-gray-900">2FA habilitado</p>
@@ -230,9 +228,7 @@
</div>
{:else}
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<span class="text-xs font-bold text-gray-400">2FA</span>
</div>
<div>
<p class="text-sm font-medium text-gray-900">2FA no habilitado</p>
@@ -306,7 +302,7 @@
<!-- Backup codes -->
{#if showBackupCodes && backupCodes.length > 0}
<div class="mt-5 pt-5 border-t border-green-200 bg-green-50 rounded-b-lg -mx-6 -mb-5 px-6 pb-5">
<h4 class="font-semibold text-green-900 mb-1">2FA activado — Guarda tus códigos de respaldo</h4>
<h4 class="font-semibold text-green-900 mb-1">2FA activado — Guarda tus códigos de respaldo</h4>
<p class="text-sm text-green-700 mb-3">
Estos códigos son de <strong>un solo uso</strong>. Guárdalos en un lugar seguro para acceder sin tu dispositivo.
</p>

View File

@@ -55,7 +55,7 @@
<select
bind:value={selectedPeriod}
on:change={loadDashboard}
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value={7}>Últimos 7 días</option>
<option value={30}>Últimos 30 días</option>
@@ -66,7 +66,7 @@
{#if isLoading}
<div class="mt-8 text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-700"></div>
<p class="mt-2 text-sm text-gray-500">Cargando métricas...</p>
</div>
{:else if dashboardData}
@@ -75,106 +75,70 @@
<!-- Response SLA -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Response SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.response_sla.compliance_percentage)}">
{dashboardData.response_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.response_sla)}">
{getTrendIcon(dashboardData.trends.response_sla)} {dashboardData.trends.response_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.response_sla.met_count} / {dashboardData.response_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Response SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.response_sla.compliance_percentage)}">
{dashboardData.response_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.response_sla)}">
{getTrendIcon(dashboardData.trends.response_sla)} {dashboardData.trends.response_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.response_sla.met_count} / {dashboardData.response_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
<!-- Resolution SLA -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Resolution SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.resolution_sla.compliance_percentage)}">
{dashboardData.resolution_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.resolution_sla)}">
{getTrendIcon(dashboardData.trends.resolution_sla)} {dashboardData.trends.resolution_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.resolution_sla.met_count} / {dashboardData.resolution_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Resolution SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.resolution_sla.compliance_percentage)}">
{dashboardData.resolution_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.resolution_sla)}">
{getTrendIcon(dashboardData.trends.resolution_sla)} {dashboardData.trends.resolution_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.resolution_sla.met_count} / {dashboardData.resolution_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
<!-- Active Violations -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Violaciones Activas</dt>
<dd class="text-2xl font-semibold text-red-600">
{dashboardData.active_violations}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/violations" class="text-indigo-600 hover:text-indigo-900">Ver detalles →</a>
</dd>
</dl>
</div>
</div>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Violaciones Activas</dt>
<dd class="text-2xl font-semibold text-red-600">
{dashboardData.active_violations}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/violations" class="text-blue-700 hover:text-blue-900">Ver detalles →</a>
</dd>
</dl>
</div>
</div>
<!-- At Risk Tickets -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Tickets en Riesgo</dt>
<dd class="text-2xl font-semibold text-yellow-600">
{dashboardData.at_risk_tickets}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/at-risk" class="text-indigo-600 hover:text-indigo-900">Ver lista →</a>
</dd>
</dl>
</div>
</div>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Tickets en Riesgo</dt>
<dd class="text-2xl font-semibold text-yellow-600">
{dashboardData.at_risk_tickets}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/at-risk" class="text-blue-700 hover:text-blue-900">Ver lista →</a>
</dd>
</dl>
</div>
</div>
</div>
@@ -257,28 +221,18 @@
href="/sla/violations"
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg class="mr-3 h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Ver Violaciones
</a>
<a
href="/categories"
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg class="mr-3 h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configurar SLAs
</a>
<a
href="/tickets"
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg class="mr-3 h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Ver Todos los Tickets
</a>
</div>

View File

@@ -6,12 +6,62 @@
let isLoading = true;
let atRiskTickets: any[] = [];
let threshold = 80;
let page = 1;
let perPage = 10;
let totalTickets = 0;
// Filtros
let filterPriority = 'ALL';
let filterRiskLevel = 'ALL';
let filterSLAType = 'ALL';
let searchQuery = '';
// Tickets filtrados
$: filteredTickets = atRiskTickets.filter(ticket => {
// Filtro por prioridad
if (filterPriority !== 'ALL' && ticket.ticket.priority !== filterPriority) {
return false;
}
// Filtro por nivel de riesgo
if (filterRiskLevel !== 'ALL') {
if (filterRiskLevel === 'CRITICAL' && ticket.risk_percentage < 95) return false;
if (filterRiskLevel === 'HIGH' && (ticket.risk_percentage < 90 || ticket.risk_percentage >= 95)) return false;
if (filterRiskLevel === 'MEDIUM' && (ticket.risk_percentage < 80 || ticket.risk_percentage >= 90)) return false;
}
// Filtro por tipo de SLA
if (filterSLAType !== 'ALL' && ticket.sla_type !== filterSLAType) {
return false;
}
// Búsqueda por número de ticket o asunto
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesNumber = ticket.ticket.ticket_number?.toLowerCase().includes(query);
const matchesSubject = ticket.ticket.subject?.toLowerCase().includes(query);
const matchesCategory = ticket.category?.name?.toLowerCase().includes(query);
return matchesNumber || matchesSubject || matchesCategory;
}
return true;
});
$: totalTickets = filteredTickets.length;
$: paginatedTickets = filteredTickets.slice((page - 1) * perPage, page * perPage);
$: totalPages = Math.ceil(totalTickets / perPage);
// Reset página cuando cambian filtros
$: if (filterPriority || filterRiskLevel || filterSLAType || searchQuery) {
page = 1;
}
async function loadAtRiskTickets() {
isLoading = true;
try {
const data: any = await api.get(`/sla/at-risk?threshold=${threshold}`);
atRiskTickets = data.tickets || [];
page = 1; // Reset a primera página al cambiar filtros
} catch (e: any) {
toast.error(e.message || 'Error cargando tickets en riesgo');
atRiskTickets = [];
@@ -20,6 +70,24 @@
}
}
function clearFilters() {
filterPriority = 'ALL';
filterRiskLevel = 'ALL';
filterSLAType = 'ALL';
searchQuery = '';
}
function getActiveFiltersCount(): number {
let count = 0;
if (filterPriority !== 'ALL') count++;
if (filterRiskLevel !== 'ALL') count++;
if (filterSLAType !== 'ALL') count++;
if (searchQuery) count++;
return count;
}
$: activeFiltersCount = getActiveFiltersCount();
function formatHours(hours: number): string {
if (hours < 1) {
return `${Math.round(hours * 60)} min`;
@@ -33,10 +101,17 @@
}
function getRiskColor(percentage: number): string {
if (percentage >= 95) return 'bg-red-100 text-red-800 border-red-200';
if (percentage >= 90) return 'bg-orange-100 text-orange-800 border-orange-200';
if (percentage >= 80) return 'bg-yellow-100 text-yellow-800 border-yellow-200';
return 'bg-blue-100 text-blue-800 border-blue-200';
if (percentage >= 95) return 'border-red-500';
if (percentage >= 90) return 'border-orange-500';
if (percentage >= 80) return 'border-yellow-500';
return 'border-blue-500';
}
function getRiskBadgeColor(percentage: number): string {
if (percentage >= 95) return 'bg-red-100 text-red-800';
if (percentage >= 90) return 'bg-orange-100 text-orange-800';
if (percentage >= 80) return 'bg-yellow-100 text-yellow-800';
return 'bg-blue-100 text-blue-800';
}
function getRiskLabel(percentage: number): string {
@@ -60,6 +135,20 @@
return type === 'response' ? 'Respuesta' : 'Resolución';
}
function nextPage() {
if (page < totalPages) {
page++;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function prevPage() {
if (page > 1) {
page--;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
onMount(loadAtRiskTickets);
</script>
@@ -76,7 +165,7 @@
<select
bind:value={threshold}
on:change={loadAtRiskTickets}
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value={70}>70% del tiempo</option>
<option value={80}>80% del tiempo</option>
@@ -91,115 +180,209 @@
</div>
</div>
<!-- Info Banner -->
<div class="mt-6 bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<!-- Filtros -->
<div class="mt-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">Filtros</h3>
{#if activeFiltersCount > 0}
<button
on:click={clearFilters}
class="text-xs font-medium text-blue-600 hover:text-blue-800"
>
Limpiar ({activeFiltersCount})
</button>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<!-- Búsqueda -->
<div>
<label for="search" class="block text-xs font-medium text-gray-700 mb-1">Buscar</label>
<input
id="search"
type="text"
bind:value={searchQuery}
placeholder="Ticket, asunto, categoría..."
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
/>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
Mostrando tickets que han consumido {threshold}% o más de su tiempo SLA.
Estos tickets requieren atención prioritaria para evitar violaciones.
</p>
<!-- Filtro por Prioridad -->
<div>
<label for="priority" class="block text-xs font-medium text-gray-700 mb-1">Prioridad</label>
<select
id="priority"
bind:value={filterPriority}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="ALL">Todas</option>
<option value="URGENT">Urgente</option>
<option value="HIGH">Alta</option>
<option value="MEDIUM">Media</option>
<option value="LOW">Baja</option>
</select>
</div>
<!-- Filtro por Nivel de Riesgo -->
<div>
<label for="riskLevel" class="block text-xs font-medium text-gray-700 mb-1">Nivel de Riesgo</label>
<select
id="riskLevel"
bind:value={filterRiskLevel}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="ALL">Todos</option>
<option value="CRITICAL">Crítico (≥95%)</option>
<option value="HIGH">Alto (90-95%)</option>
<option value="MEDIUM">Medio (80-90%)</option>
</select>
</div>
<!-- Filtro por Tipo de SLA -->
<div>
<label for="slaType" class="block text-xs font-medium text-gray-700 mb-1">Tipo de SLA</label>
<select
id="slaType"
bind:value={filterSLAType}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="ALL">Todos</option>
<option value="response">Respuesta</option>
<option value="resolution">Resolución</option>
</select>
</div>
</div>
<!-- Contador de resultados -->
<div class="mt-3 pt-3 border-t border-gray-200">
<p class="text-xs text-gray-600">
Mostrando <span class="font-semibold text-gray-900">{totalTickets}</span>
{totalTickets === 1 ? 'ticket' : 'tickets'}
{#if activeFiltersCount > 0}
de <span class="font-semibold">{atRiskTickets.length}</span> totales
{/if}
</p>
</div>
</div>
<!-- Summary Stats -->
{#if !isLoading && filteredTickets.length > 0}
<div class="mt-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Resumen por Nivel de Riesgo</h3>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="bg-gray-50 rounded-lg p-3 border-l-4 border-gray-400">
<p class="text-xs font-medium text-gray-600">Total en Riesgo</p>
<p class="mt-1 text-2xl font-bold text-gray-900">{totalTickets}</p>
</div>
<div class="bg-red-50 rounded-lg p-3 border-l-4 border-red-500">
<p class="text-xs font-medium text-gray-600">Crítico (≥95%)</p>
<p class="mt-1 text-2xl font-bold text-red-600">
{filteredTickets.filter(t => t.risk_percentage >= 95).length}
</p>
</div>
<div class="bg-orange-50 rounded-lg p-3 border-l-4 border-orange-500">
<p class="text-xs font-medium text-gray-600">Alto (90-95%)</p>
<p class="mt-1 text-2xl font-bold text-orange-600">
{filteredTickets.filter(t => t.risk_percentage >= 90 && t.risk_percentage < 95).length}
</p>
</div>
<div class="bg-yellow-50 rounded-lg p-3 border-l-4 border-yellow-500">
<p class="text-xs font-medium text-gray-600">Medio (80-90%)</p>
<p class="mt-1 text-2xl font-bold text-yellow-600">
{filteredTickets.filter(t => t.risk_percentage >= 80 && t.risk_percentage < 90).length}
</p>
</div>
</div>
</div>
{/if}
<!-- Info Banner -->
<div class="mt-4 bg-amber-50 border-l-4 border-amber-500 p-3 rounded-lg">
<p class="text-xs text-amber-800">
<strong class="font-bold">{totalTickets}</strong> {totalTickets === 1 ? 'ticket' : 'tickets'} consumiendo <strong>{threshold}%</strong> o más del tiempo SLA.
</p>
</div>
<!-- Risk Tickets List -->
<div class="mt-6 space-y-4">
<div class="mt-4 space-y-2">
{#if isLoading}
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
<p class="mt-2 text-sm text-gray-500">Cargando tickets en riesgo...</p>
<div class="text-center py-12 bg-white rounded-lg shadow">
<div class="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div>
<p class="mt-3 text-sm text-gray-600">Cargando tickets en riesgo...</p>
</div>
{:else if atRiskTickets.length === 0}
<div class="bg-white shadow rounded-lg text-center py-12">
<svg class="mx-auto h-12 w-12 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="mt-2 text-lg font-medium text-gray-900">¡Todo bajo control!</p>
<p class="mt-2 text-base font-medium text-gray-900">Todo bajo control</p>
<p class="mt-1 text-sm text-gray-500">No hay tickets en riesgo de violar SLA</p>
</div>
{:else if filteredTickets.length === 0}
<div class="bg-white shadow rounded-lg text-center py-12">
<p class="mt-2 text-base font-medium text-gray-900">Sin resultados</p>
<p class="mt-1 text-sm text-gray-500">No se encontraron tickets con los filtros aplicados</p>
<button
on:click={clearFilters}
class="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
>
Limpiar filtros
</button>
</div>
{:else}
{#each atRiskTickets as ticket}
<div class="bg-white shadow rounded-lg overflow-hidden border-l-4 {getRiskColor(ticket.risk_percentage)}">
<div class="px-6 py-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
{#each paginatedTickets as ticket}
<div class="bg-white shadow-sm rounded-lg overflow-hidden border-l-3 {getRiskColor(ticket.risk_percentage)} hover:shadow transition-shadow">
<div class="px-3 py-2">
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a
href="/tickets/{ticket.ticket.id}"
class="text-lg font-semibold text-indigo-600 hover:text-indigo-900"
class="text-sm font-semibold text-blue-600 hover:text-blue-800"
>
{ticket.ticket.ticket_number}
</a>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {getPriorityColor(ticket.ticket.priority)}">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium {getPriorityColor(ticket.ticket.priority)}">
{ticket.ticket.priority}
</span>
<span class="text-sm text-gray-500">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs bg-gray-100 text-gray-700">
{getSLATypeLabel(ticket.sla_type)}
</span>
</div>
<p class="mt-1 text-sm text-gray-900">{ticket.ticket.subject}</p>
<p class="mt-0.5 text-xs text-gray-700 line-clamp-1">{ticket.ticket.subject}</p>
{#if ticket.category}
<div class="mt-2 text-xs text-gray-500">
📂 {ticket.category.name}
<span class="text-gray-400">
(SLA: {ticket.sla_type === 'response' ? ticket.category.sla_response_hours : ticket.category.sla_resolution_hours}h)
</span>
</div>
{/if}
{#if ticket.assigned_to}
<div class="mt-2 text-xs text-gray-500">
👤 Asignado a: <span class="text-gray-900">{ticket.assigned_to.first_name} {ticket.assigned_to.last_name}</span>
</div>
{/if}
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500">
{#if ticket.category}
<span class="truncate">{ticket.category.name}</span>
{/if}
{#if ticket.assigned_to}
<span class="truncate">{ticket.assigned_to.first_name} {ticket.assigned_to.last_name}</span>
{/if}
</div>
</div>
<div class="ml-6 flex-shrink-0 text-right">
<div class="text-sm font-medium {getRiskColor(ticket.risk_percentage)} inline-flex items-center px-3 py-1 rounded-full border">
{getRiskLabel(ticket.risk_percentage)}
<div class="flex items-center gap-2">
<div class="text-xs font-semibold {getRiskBadgeColor(ticket.risk_percentage)} px-2 py-0.5 rounded">
{ticket.risk_percentage.toFixed(0)}%
</div>
<div class="mt-2 text-sm">
<span class="font-semibold text-red-600">
Progreso: {ticket.risk_percentage.toFixed(1)}%
</span>
</div>
<div class="mt-1 text-xs text-gray-500">
Quedan: <span class="font-medium text-orange-600">{formatHours(ticket.time_remaining_hours)}</span>
<div class="text-xs text-gray-600">
{formatHours(ticket.time_remaining_hours)}
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="mt-4">
<div class="relative">
<div class="overflow-hidden h-2 text-xs flex rounded bg-gray-200">
<div
style="width: {ticket.risk_percentage}%"
class="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center {ticket.risk_percentage >= 95 ? 'bg-red-500' : ticket.risk_percentage >= 90 ? 'bg-orange-500' : ticket.risk_percentage >= 80 ? 'bg-yellow-500' : 'bg-blue-500'}"
></div>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>0%</span>
<span class="text-orange-600 font-medium">{threshold}% (umbral)</span>
<span>100%</span>
</div>
</div>
</div>
<div class="px-3 pb-2">
<div class="overflow-hidden h-1 rounded-full bg-gray-200">
<div
style="width: {ticket.risk_percentage}%"
class="h-full transition-all {ticket.risk_percentage >= 95 ? 'bg-red-500' : ticket.risk_percentage >= 90 ? 'bg-orange-500' : ticket.risk_percentage >= 80 ? 'bg-yellow-500' : 'bg-blue-500'}"
></div>
</div>
</div>
<div class="bg-gray-50 px-6 py-3 flex justify-end gap-3">
<div class="bg-gray-50 px-3 py-1.5 flex justify-end border-t border-gray-100">
<a
href="/tickets/{ticket.ticket.id}"
class="text-sm font-medium text-indigo-600 hover:text-indigo-900"
class="text-xs font-medium text-blue-600 hover:text-blue-800"
>
Ver ticket
Ver ticket
</a>
</div>
</div>
@@ -207,34 +390,61 @@
{/if}
</div>
<!-- Summary Stats -->
{#if !isLoading && atRiskTickets.length > 0}
<div class="mt-8 bg-gray-50 rounded-lg p-6">
<h3 class="text-sm font-medium text-gray-900 mb-4">Resumen</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4 text-center">
<!-- Pagination -->
{#if !isLoading && totalPages > 1}
<div class="mt-6 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg shadow">
<div class="flex flex-1 justify-between sm:hidden">
<button
on:click={prevPage}
disabled={page === 1}
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
on:click={nextPage}
disabled={page === totalPages}
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-500">Total en Riesgo</p>
<p class="text-2xl font-semibold text-gray-900">{atRiskTickets.length}</p>
</div>
<div>
<p class="text-sm text-gray-500">Riesgo Crítico (≥95%)</p>
<p class="text-2xl font-semibold text-red-600">
{atRiskTickets.filter(t => t.risk_percentage >= 95).length}
<p class="text-sm text-gray-700">
Mostrando
<span class="font-medium">{(page - 1) * perPage + 1}</span>
a
<span class="font-medium">{Math.min(page * perPage, totalTickets)}</span>
de
<span class="font-medium">{totalTickets}</span>
resultados
</p>
</div>
<div>
<p class="text-sm text-gray-500">Riesgo Alto (≥90%)</p>
<p class="text-2xl font-semibold text-orange-600">
{atRiskTickets.filter(t => t.risk_percentage >= 90 && t.risk_percentage < 95).length}
</p>
</div>
<div>
<p class="text-sm text-gray-500">Riesgo Medio (≥80%)</p>
<p class="text-2xl font-semibold text-yellow-600">
{atRiskTickets.filter(t => t.risk_percentage >= 80 && t.risk_percentage < 90).length}
</p>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<button
on:click={prevPage}
disabled={page === 1}
class="relative inline-flex items-center rounded-l-md px-3 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300">
Página {page} de {totalPages}
</span>
<button
on:click={nextPage}
disabled={page === totalPages}
class="relative inline-flex items-center rounded-r-md px-3 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</nav>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -7,7 +7,7 @@
let violations: any[] = [];
let total = 0;
let page = 1;
let perPage = 20;
let perPage = 10;
let totalPages = 0;
// Filtros
@@ -74,7 +74,7 @@
}
function getSLATypeColor(type: string): string {
return type === 'response' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800';
return type === 'response' ? 'bg-orange-100 text-orange-800' : 'bg-red-100 text-red-800';
}
function handleFilterChange() {
@@ -130,7 +130,7 @@
id="slaType"
bind:value={slaTypeFilter}
on:change={handleFilterChange}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Todos</option>
<option value="response">Respuesta</option>
@@ -144,7 +144,7 @@
id="category"
bind:value={categoryFilter}
on:change={handleFilterChange}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Todas</option>
{#each categories as cat}
@@ -159,7 +159,7 @@
id="priority"
bind:value={priorityFilter}
on:change={handleFilterChange}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Todas</option>
<option value="LOW">Baja</option>
@@ -186,22 +186,14 @@
</div>
<!-- Stats Summary -->
<div class="mt-6 bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">
<strong>{total}</strong> violaciones activas encontradas
<div class="mt-6 bg-red-50 border-l-4 border-red-500 p-4 rounded-lg">
<p class="text-sm text-red-800">
<strong class="font-bold">{total}</strong> {total === 1 ? 'violación activa' : 'violaciones activas'} encontradas
{#if total > 0}
- Requieren atención inmediata
{/if}
</p>
</div>
</div>
</div>
<!-- Violations Table -->
@@ -238,18 +230,16 @@
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr>
<td colspan="7" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
<p class="mt-2 text-sm text-gray-500">Cargando violaciones...</p>
<td colspan="7" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div>
<p class="mt-4 text-sm text-gray-600 font-medium">Cargando violaciones...</p>
</td>
</tr>
{:else if violations.length === 0}
<tr>
<td colspan="7" class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="mt-2 text-sm text-gray-500">Excelente! No hay violaciones de SLA activas</p>
<td colspan="7" class="text-center py-12">
<p class="mt-3 text-base font-medium text-gray-900">¡Excelente trabajo!</p>
<p class="mt-1 text-sm text-gray-600">No hay violaciones de SLA activas</p>
</td>
</tr>
{:else}
@@ -259,7 +249,7 @@
<div class="flex flex-col">
<a
href="/tickets/{violation.ticket.id}"
class="font-medium text-indigo-600 hover:text-indigo-900"
class="font-medium text-blue-700 hover:text-blue-900"
>
{violation.ticket.ticket_number}
</a>
@@ -290,11 +280,14 @@
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span class="font-semibold text-red-600">
{formatHours(violation.hours_overdue)}
</span>
<div class="text-xs text-gray-500">
vencido
<div>
<span class="font-bold text-red-600 text-base">
{formatHours(violation.hours_overdue)}
</span>
<div class="text-xs text-gray-600">
vencido
</div>
</div>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
@@ -314,7 +307,7 @@
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<a
href="/tickets/{violation.ticket.id}"
class="text-indigo-600 hover:text-indigo-900"
class="text-blue-700 hover:text-blue-900"
>
Ver ticket →
</a>

View File

@@ -67,7 +67,7 @@
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
>
Nuevo Sistema
</button>
@@ -100,12 +100,12 @@
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{system.name}</td>
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{system.description || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={system.is_active} class:text-green-800={system.is_active} class:bg-red-100={!system.is_active} class:text-red-800={!system.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {system.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{system.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(system)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
<button on:click={() => openEditModal(system)} class="text-blue-700 hover:text-blue-900">Editar</button>
</td>
</tr>
{/each}
@@ -122,24 +122,24 @@
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required 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">
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
<textarea id="description" bind:value={formData.description} rows="3" 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"></textarea>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"></textarea>
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>

View File

@@ -101,7 +101,7 @@
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
>
Nuevo Cliente
</button>
@@ -143,7 +143,7 @@
<button
type="button"
on:click={() => toggleTenantStatus(tenant)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
role="switch"
aria-checked={tenant.status === 'active'}
>
@@ -153,13 +153,13 @@
</button>
<!-- Badge de Estado -->
<span class:bg-green-100={tenant.status === 'active'} class:text-green-800={tenant.status === 'active'} class:bg-yellow-100={tenant.status === 'suspended'} class:text-yellow-800={tenant.status === 'suspended'} class:bg-red-100={tenant.status === 'inactive'} class:text-red-800={tenant.status === 'inactive'} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {tenant.status === 'active' ? 'bg-gray-50 text-green-700 border-green-200' : tenant.status === 'suspended' ? 'bg-gray-50 text-orange-700 border-orange-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{tenant.status === 'active' ? 'Activo' : tenant.status === 'suspended' ? 'Suspendido' : 'Inactivo'}
</span>
</div>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(tenant)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
<button on:click={() => openEditModal(tenant)} class="text-blue-700 hover:text-blue-900">Editar</button>
</td>
</tr>
{/each}
@@ -176,28 +176,28 @@
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required 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">
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700">Slug (Identificador)</label>
<input type="text" id="slug" bind:value={formData.slug} required 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">
<input type="text" id="slug" bind:value={formData.slug} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<p class="text-xs text-gray-500 mt-1">Usado en URLs y subdominios.</p>
</div>
<div>
<label for="domain" class="block text-sm font-medium text-gray-700">Dominio Personalizado</label>
<input type="text" id="domain" bind:value={formData.domain} 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">
<input type="text" id="domain" bind:value={formData.domain} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="contact_email" class="block text-sm font-medium text-gray-700">Email de Contacto</label>
<input type="email" id="contact_email" bind:value={formData.contact_email} 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">
<input type="email" id="contact_email" bind:value={formData.contact_email} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="contact_phone" class="block text-sm font-medium text-gray-700">Teléfono de Contacto</label>
<input type="text" id="contact_phone" bind:value={formData.contact_phone} 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">
<input type="text" id="contact_phone" bind:value={formData.contact_phone} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
@@ -208,7 +208,7 @@
<button
type="button"
on:click={() => formData.status = formData.status === 'active' ? 'inactive' : 'active'}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {formData.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {formData.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
role="switch"
aria-checked={formData.status === 'active'}
>
@@ -229,7 +229,7 @@
type="checkbox"
checked={formData.status === 'suspended'}
on:change={(e) => formData.status = e.target.checked ? 'suspended' : 'active'}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 h-4 w-4"
class="rounded border-gray-300 text-blue-700 focus:ring-blue-500 h-4 w-4"
/>
<span class="ml-2 text-sm text-gray-600">Marcar como suspendido temporalmente</span>
</label>
@@ -238,10 +238,10 @@
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
{#if editingTenant}

View File

@@ -312,7 +312,7 @@
<div class="flex gap-5 px-4 py-5 mx-auto max-w-full sm:px-6 lg:px-8">
<aside class="w-60 flex-shrink-0 space-y-4">
<div class="bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
<div class="bg-indigo-600 px-4 py-3">
<div class="bg-blue-700 px-4 py-3">
<h2 class="text-sm font-semibold text-white tracking-wide">Organización</h2>
</div>
<ul class="divide-y divide-gray-100">
@@ -320,7 +320,7 @@
<button
class="w-full text-left px-4 py-2.5 flex items-center justify-between text-sm transition-colors
{filterTenantId === ''
? 'bg-indigo-50 text-indigo-700 font-semibold'
? 'bg-blue-50 text-blue-700 font-semibold'
: 'text-gray-700 hover:bg-gray-50'}"
on:click={() => {
filterTenantId = '';
@@ -338,7 +338,7 @@
<button
class="w-full text-left px-4 py-2.5 flex items-center justify-between text-sm transition-colors
{filterTenantId === tenant.id
? 'bg-indigo-50 text-indigo-700 font-semibold'
? 'bg-blue-50 text-blue-700 font-semibold'
: 'text-gray-700 hover:bg-gray-50'}"
on:click={() => {
filterTenantId = tenant.id;
@@ -347,7 +347,7 @@
>
<span class="truncate pr-1">{tenant.name}</span>
<span
class="text-xs bg-indigo-100 text-indigo-600 rounded-full px-2 py-0.5 font-medium flex-shrink-0"
class="text-xs bg-gray-100 text-blue-700 rounded-full px-2 py-0.5 font-medium flex-shrink-0 border border-gray-200"
>
{tenantCounts[tenant.id] ?? 0}
</span>
@@ -366,7 +366,7 @@
<select
bind:value={filterStatus}
on:change={() => (categoryPages = {})}
class="block w-full rounded-md border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500 border px-2 py-1.5"
class="block w-full rounded-md border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 border px-2 py-1.5"
>
<option value="">Todos</option>
{#each STATUSES as s}
@@ -380,7 +380,7 @@
<select
bind:value={filterPriority}
on:change={() => (categoryPages = {})}
class="block w-full rounded-md border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500 border px-2 py-1.5"
class="block w-full rounded-md border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 border px-2 py-1.5"
>
<option value="">Todas</option>
{#each PRIORITIES as p}
@@ -398,7 +398,7 @@
searchQuery = '';
categoryPages = {};
}}
class="w-full text-xs text-indigo-600 hover:text-indigo-800 font-medium text-center pt-1"
class="w-full text-xs text-blue-700 hover:text-blue-800 font-medium text-center pt-1"
>
Limpiar filtros
</button>
@@ -430,107 +430,44 @@
</div>
<button
on:click={openCreateModal}
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md shadow-sm hover:bg-indigo-700 transition-colors"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-md shadow-sm hover:bg-blue-800 transition-colors"
>
+ Nuevo Ticket
</button>
</div>
<div class="grid grid-cols-4 gap-3">
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4 flex items-center gap-3">
<div class="bg-gray-100 rounded-lg p-2">
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</div>
<div>
<p class="text-xs text-gray-500 font-medium">Total</p>
<p class="text-2xl font-bold text-gray-900">{kpiTotal}</p>
</div>
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<p class="text-xs text-gray-500 font-medium">Total</p>
<p class="text-2xl font-bold text-gray-900">{kpiTotal}</p>
</div>
<div class="bg-white rounded-xl border border-red-200 shadow-sm p-4 flex items-center gap-3">
<div class="bg-red-50 rounded-lg p-2">
<svg class="h-5 w-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
</div>
<div>
<p class="text-xs text-red-500 font-medium">Urgentes</p>
<p class="text-2xl font-bold text-red-600">{kpiUrgent}</p>
</div>
<div class="bg-white rounded-xl border border-red-200 shadow-sm p-4">
<p class="text-xs text-red-500 font-medium">Urgentes</p>
<p class="text-2xl font-bold text-red-600">{kpiUrgent}</p>
</div>
<div class="bg-white rounded-xl border border-blue-200 shadow-sm p-4 flex items-center gap-3">
<div class="bg-blue-50 rounded-lg p-2">
<svg class="h-5 w-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div>
<p class="text-xs text-blue-500 font-medium">Activos</p>
<p class="text-2xl font-bold text-blue-600">{kpiActive}</p>
</div>
<div class="bg-white rounded-xl border border-blue-200 shadow-sm p-4">
<p class="text-xs text-blue-500 font-medium">Activos</p>
<p class="text-2xl font-bold text-blue-600">{kpiActive}</p>
</div>
<div
class="bg-white rounded-xl border border-green-200 shadow-sm p-4 flex items-center gap-3"
>
<div class="bg-green-50 rounded-lg p-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p class="text-xs text-green-500 font-medium">Resueltos</p>
<p class="text-2xl font-bold text-green-600">{kpiResolved}</p>
</div>
<div class="bg-white rounded-xl border border-green-200 shadow-sm p-4">
<p class="text-xs text-green-500 font-medium">Resueltos</p>
<p class="text-2xl font-bold text-green-600">{kpiResolved}</p>
</div>
</div>
<div class="relative">
<svg
class="pointer-events-none absolute left-3 top-2.5 h-4 w-4 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-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"
/>
</svg>
<input
type="text"
placeholder="Buscar por número, asunto, usuario..."
bind:value={searchQuery}
on:input={() => (categoryPages = {})}
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{#if isLoading}
<div class="flex justify-center items-center py-16 text-gray-400 text-sm">
<svg class="animate-spin h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24">
<svg class="animate-spin h-5 w-5 mr-2 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
@@ -568,45 +505,32 @@
/>
<span class="font-semibold text-gray-800 text-sm">{group.name}</span>
<span
class="text-xs bg-indigo-100 text-indigo-700 font-medium rounded-full px-2 py-0.5"
class="text-xs bg-gray-100 text-blue-700 font-medium rounded-full px-2 py-0.5 border border-gray-200"
>
{group.tickets.length}
</span>
{#if group.urgCount > 0}
<span
class="text-xs bg-red-100 text-red-700 font-semibold rounded-full px-2 py-0.5"
class="text-xs bg-gray-50 text-red-700 font-semibold rounded-full px-2 py-0.5 border border-red-200"
>
? {group.urgCount} urgente{group.urgCount > 1 ? 's' : ''}
{group.urgCount} urgente{group.urgCount > 1 ? 's' : ''}
</span>
{/if}
{#if group.highCount > 0}
<span
class="text-xs bg-orange-100 text-orange-700 font-medium rounded-full px-2 py-0.5"
class="text-xs bg-gray-50 text-orange-700 font-medium rounded-full px-2 py-0.5 border border-orange-200"
>
? {group.highCount} alta{group.highCount > 1 ? 's' : ''}
{group.highCount} alta{group.highCount > 1 ? 's' : ''}
</span>
{/if}
{#if group.closedCount > 0}
<span
class="text-xs bg-gray-100 text-gray-500 font-medium rounded-full px-2 py-0.5"
>
? {group.closedCount} cerrado{group.closedCount > 1 ? 's' : ''}
{group.closedCount} cerrado{group.closedCount > 1 ? 's' : ''}
</span>
{/if}
</div>
<svg
class="h-4 w-4 text-gray-400 transition-transform {isCollapsed ? '' : 'rotate-180'}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if !isCollapsed}
@@ -653,7 +577,7 @@
<tr
class="cursor-pointer transition-all {rowBorderClass(ticket)} {isClosed
? 'opacity-55 hover:opacity-80 bg-gray-50/50'
: 'hover:bg-indigo-50/60'}"
: 'hover:bg-blue-50/60'}"
on:click={() => goto(`/tickets/${ticket.id}`)}
>
<td
@@ -709,7 +633,7 @@
<td class="px-4 py-2.5 whitespace-nowrap text-right">
<button
on:click|stopPropagation={() => openEditModal(ticket)}
class="text-indigo-600 hover:text-indigo-900 font-medium mr-2"
class="text-blue-700 hover:text-blue-900 font-medium mr-2"
>Editar</button
>
<button
@@ -745,7 +669,7 @@
on:click={() => setPage(catId, p)}
class="px-2.5 py-1 rounded text-xs border font-medium transition-colors
{p === page
? 'bg-indigo-600 border-indigo-600 text-white'
? 'bg-blue-700 border-blue-700 text-white'
: 'border-gray-300 text-gray-600 hover:bg-gray-100'}">{p}</button
>
{/each}
@@ -776,7 +700,7 @@
type="text"
bind:value={formData.subject}
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
placeholder="Breve descripción del problema"
/>
</div>
@@ -786,7 +710,7 @@
bind:value={formData.description}
required
rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
placeholder="Describe el problema en detalle..."
/>
</div>
@@ -830,7 +754,7 @@
<div class="sm:grid sm:grid-cols-2 sm:gap-3 mt-5">
<button
type="submit"
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-sm font-medium text-white hover:bg-indigo-700 sm:col-start-2"
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-sm font-medium text-white hover:bg-blue-800 sm:col-start-2"
>
Crear Ticket
</button>
@@ -896,7 +820,7 @@
<div class="sm:grid sm:grid-cols-2 sm:gap-3 mt-5">
<button
type="submit"
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-sm font-medium text-white hover:bg-indigo-700 sm:col-start-2"
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-sm font-medium text-white hover:bg-blue-800 sm:col-start-2"
>
Guardar Cambios
</button>
@@ -913,20 +837,7 @@
<Modal open={showDeleteModal} title="Eliminar Ticket" on:close={() => (showDeleteModal = false)}>
<div class="space-y-4">
<div class="bg-red-50 border border-red-200 rounded-md p-4 flex gap-3">
<svg
class="h-5 w-5 text-red-400 mt-0.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<div>
<h3 class="text-sm font-medium text-red-800">¿Eliminar este ticket?</h3>
<p class="text-sm text-red-700 mt-1">

View File

@@ -143,16 +143,14 @@
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
{#if isLoading}
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-700" />
<p class="mt-2 text-gray-600">Cargando ticket...</p>
</div>
{:else if ticket}
<!-- Breadcrumb -->
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-6">
<a href="/tickets" class="hover:text-indigo-600">Tickets</a>
<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="M9 5l7 7-7 7" />
</svg>
<a href="/tickets" class="hover:text-blue-700">Tickets</a>
<span>/</span>
<span>#{ticket.ticket_number || ticket.id.substring(0, 8)}</span>
</div>
@@ -222,21 +220,9 @@
>
<div class="flex items-center space-x-3">
<div
class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0"
class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0"
>
<svg
class="w-5 h-5 text-indigo-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>
<span class="text-xs font-bold text-blue-700">ADJ</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
@@ -255,17 +241,10 @@
</div>
<button
on:click={() => handleDownloadAttachment(attachment)}
class="inline-flex items-center p-2 text-sm font-medium text-indigo-600 hover:bg-indigo-50 rounded-md transition-colors"
class="inline-flex items-center px-2 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 rounded-md transition-colors"
title="Descargar {attachment.original_filename}"
>
<svg class="w-5 h-5" 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>
Descargar
</button>
</div>
{/each}
@@ -289,15 +268,15 @@
{#each comments as comment}
<div class="flex space-x-3">
<div
class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0"
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<span class="text-indigo-600 text-xs font-medium">
<span class="text-blue-700 text-xs font-medium">
{comment.author_name
? comment.author_name
.split(' ')
.map(n => n[0])
.join('')
: '??'}
: 'U'}
</span>
</div>
<div class="flex-1 min-w-0">
@@ -309,7 +288,7 @@
{formatDate(comment.created_at)}
</span>
{#if comment.is_internal}
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">
<span class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200">
Interno
</span>
{/if}
@@ -328,7 +307,7 @@
<form on:submit|preventDefault={handleAddComment} class="space-y-4">
<textarea
rows="4"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment}
disabled={isSubmittingComment}
@@ -337,7 +316,7 @@
<div class="flex justify-end">
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
disabled={isSubmittingComment || !newComment.trim()}
>
{#if isSubmittingComment}
@@ -398,7 +377,7 @@
<!-- SLA Information -->
{#if ticket.sla_response_due || ticket.sla_resolution_due}
<div class="pt-4 border-t border-gray-200">
<h4 class="text-sm font-semibold text-gray-900 mb-3">⏱️ SLA (Acuerdos de Nivel de Servicio)</h4>
<h4 class="text-sm font-semibold text-gray-900 mb-3">SLA (Acuerdos de Nivel de Servicio)</h4>
{#if ticket.sla_response_due}
<div class="mb-3">
@@ -406,16 +385,16 @@
<dd class="text-sm text-gray-900 mt-1">
{formatDate(ticket.sla_response_due)}
{#if new Date(ticket.sla_response_due) < new Date() && !ticket.sla_response_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
⚠️ Vencido
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
Vencido
</span>
{:else if ticket.sla_response_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Cumplido
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
Cumplido
</span>
{:else}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
En plazo
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
En plazo
</span>
{/if}
</dd>
@@ -428,16 +407,16 @@
<dd class="text-sm text-gray-900 mt-1">
{formatDate(ticket.sla_resolution_due)}
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
⚠️ Vencido
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
Vencido
</span>
{:else if ticket.sla_resolution_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Cumplido
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
Cumplido
</span>
{:else}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
En plazo
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
En plazo
</span>
{/if}
</dd>

View File

@@ -116,7 +116,7 @@
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
>
Nuevo Usuario
</button>
@@ -154,12 +154,12 @@
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{getTenantName(user.tenant_id)}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={user.is_active} class:text-green-800={user.is_active} class:bg-red-100={!user.is_active} class:text-red-800={!user.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {user.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{user.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(user)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
<button on:click={() => openEditModal(user)} class="text-blue-700 hover:text-blue-900">Editar</button>
</td>
</tr>
{/each}
@@ -177,27 +177,27 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="first_name" bind:value={formData.first_name} required 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">
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700">Apellido</label>
<input type="text" id="last_name" bind:value={formData.last_name} required 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">
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" id="email" bind:value={formData.email} required 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">
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}</label>
<input type="password" id="password" bind:value={formData.password} 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">
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-700">Rol</label>
<select id="role" bind:value={formData.role} 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">
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
{#each ROLES as role}
<option value={role.value}>{role.label}</option>
{/each}
@@ -206,7 +206,7 @@
<div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - solo para usuarios externos)</label>
<select id="tenant" bind:value={formData.tenant_id} 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">
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<option value="">-- Ninguno (Usuario Interno) --</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
@@ -215,15 +215,15 @@
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>

View File

@@ -6,27 +6,23 @@ export default {
'bg-red-600',
'bg-blue-600',
'bg-green-600',
'bg-indigo-600',
'bg-orange-600',
'bg-yellow-500',
'bg-yellow-600',
'bg-gray-600',
// Colores de severidad/riesgo
'bg-red-50',
'bg-red-100',
'bg-red-800',
'bg-orange-50',
'bg-orange-100',
'bg-orange-300',
'bg-orange-600',
'bg-orange-700',
'bg-orange-800',
'bg-yellow-100',
'bg-yellow-300',
'bg-yellow-800',
'bg-blue-50',
'bg-blue-100',
'bg-blue-300',
'bg-blue-800',
'bg-green-50',
'bg-green-100',
'bg-green-300',
'bg-green-800',
@@ -34,7 +30,6 @@ export default {
'border-red-300',
'border-blue-200',
'border-orange-300',
'border-yellow-300',
'border-blue-300',
'border-green-300',
// Text colors
@@ -45,30 +40,26 @@ export default {
'text-orange-400',
'text-orange-600',
'text-orange-800',
'text-yellow-400',
'text-yellow-600',
'text-yellow-800',
'text-blue-400',
'text-blue-600',
'text-blue-700',
'text-blue-800',
'text-blue-900',
'text-green-600',
'text-green-800',
'text-indigo-600',
'text-white',
// Hover states
'hover:bg-red-50',
'hover:bg-red-700',
'hover:bg-orange-700',
'hover:bg-blue-50',
'hover:bg-blue-700',
'hover:bg-indigo-50',
'hover:bg-indigo-700',
'hover:bg-blue-800',
'hover:text-red-700',
// Focus rings
'focus:ring-red-500',
'focus:ring-orange-500',
'focus:ring-blue-500',
'focus:ring-indigo-500',
],
theme: {
extend: {
@@ -84,7 +75,46 @@ export default {
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
}
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
}
}
},