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:
@@ -50,4 +50,4 @@
|
||||
on:dismiss={() => toast.dismiss(toastMessage.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
1350
frontend-internal/src/routes/audit/+page.svelte.tmp
Normal file
1350
frontend-internal/src/routes/audit/+page.svelte.tmp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user