Implementacion modal ticket 1

This commit is contained in:
2026-03-11 13:48:22 -06:00
parent 1a301409de
commit 5e58d53416
22 changed files with 1172 additions and 8 deletions

View File

@@ -0,0 +1,242 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import { auth } from '$lib/stores/auth';
export let ticketId: string;
export let createdBy: string;
const dispatch = createEventDispatcher();
interface Participant {
id: string;
user_id: string;
ticket_id: string;
role: string;
user_email: string;
user_name: string;
}
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
}
let participants: Participant[] = [];
let allUsers: User[] = [];
let isLoading = true;
let isAdding = false;
let searchQuery = '';
let selectedUserId = '';
$: isCreator = $auth.user?.id === createdBy;
$: isGlobalAdmin = $auth.user?.role === 'ADMIN';
$: canManage = isCreator || isGlobalAdmin;
$: participantIds = new Set(participants.map(p => p.user_id));
$: filteredUsers = allUsers.filter(u => {
if (participantIds.has(u.id)) return false;
if (u.id === createdBy) return false;
const q = searchQuery.toLowerCase();
return !q ||
u.email.toLowerCase().includes(q) ||
`${u.first_name} ${u.last_name}`.toLowerCase().includes(q);
});
onMount(async () => {
await Promise.all([loadParticipants(), loadUsers()]);
isLoading = false;
});
async function loadParticipants() {
try {
participants = await api.get(`/tickets/${ticketId}/participants`);
} catch (e: any) {
toast.error('Error cargando participantes');
}
}
async function loadUsers() {
try {
allUsers = await api.get('/users/');
} catch (e: any) {
toast.error('Error cargando usuarios');
}
}
async function addParticipant(userId: string) {
if (!userId) return;
isAdding = true;
try {
const p = await api.post(`/tickets/${ticketId}/participants`, { user_id: userId });
participants = [...participants, p];
searchQuery = '';
toast.success('Participante agregado');
} catch (e: any) {
toast.error(e.message || 'Error al agregar participante');
} finally {
isAdding = false;
}
}
async function removeParticipant(userId: string) {
try {
await api.delete(`/tickets/${ticketId}/participants/${userId}`);
participants = participants.filter(p => p.user_id !== userId);
toast.success('Participante eliminado');
} catch (e: any) {
toast.error(e.message || 'Error al eliminar participante');
}
}
function avatarInitials(name: string, email: string): string {
if (name.trim()) {
const parts = name.trim().split(' ');
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
}
return email[0].toUpperCase();
}
function avatarColor(email: string): string {
const colors = ['bg-blue-400', 'bg-violet-400', 'bg-emerald-400', 'bg-rose-400', 'bg-amber-400', 'bg-cyan-400'];
let hash = 0;
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
return colors[Math.abs(hash) % colors.length];
}
</script>
<!-- Overlay -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => dispatch('close')}></div>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div>
<h2 class="text-lg font-semibold text-gray-900">Participantes del asunto</h2>
<p class="text-xs text-gray-400 mt-0.5">{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}</p>
</div>
<button type="button" on:click={() => dispatch('close')}
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
<!-- Buscador para agregar -->
{#if canManage}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Agregar persona</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-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-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="text"
placeholder="Buscar por nombre o email..."
bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<!-- Resultados de búsqueda -->
{#if searchQuery && filteredUsers.length > 0}
<div class="mt-2 border border-gray-200 rounded-lg overflow-hidden shadow-sm">
{#each filteredUsers.slice(0, 6) as user (user.id)}
<button
type="button"
on:click={() => addParticipant(user.id)}
disabled={isAdding}
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-blue-50 transition-colors text-left border-b border-gray-100 last:border-0"
>
<div class="w-8 h-8 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{avatarInitials(`${user.first_name} ${user.last_name}`, user.email)}
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{user.first_name} {user.last_name}</p>
<p class="text-xs text-gray-400 truncate">{user.email}</p>
</div>
<span class="ml-auto text-xs text-blue-600 font-medium flex-shrink-0">+ Agregar</span>
</button>
{/each}
</div>
{:else if searchQuery && filteredUsers.length === 0}
<p class="mt-2 text-sm text-gray-400 text-center py-2">No se encontraron usuarios disponibles</p>
{/if}
</div>
{/if}
<!-- Lista de participantes -->
<div>
<h3 class="text-sm font-medium text-gray-700 mb-3">
{canManage ? 'Participantes actuales' : 'Personas involucradas'}
</h3>
{#if isLoading}
<div class="flex justify-center py-6">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
{:else if participants.length === 0}
<div class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-200">
<svg class="w-10 h-10 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-sm text-gray-400">Aún no hay participantes</p>
{#if canManage}
<p class="text-xs text-gray-300 mt-1">Usa el buscador para agregar personas</p>
{/if}
</div>
{:else}
<div class="space-y-2">
{#each participants as p (p.id)}
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg group">
<div class="w-9 h-9 rounded-full {avatarColor(p.user_email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{avatarInitials(p.user_name, p.user_email)}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{p.user_name || p.user_email}</p>
<p class="text-xs text-gray-400 truncate">{p.user_email}</p>
</div>
<span class="text-xs text-gray-400 bg-white px-2 py-0.5 rounded-full border border-gray-200">
Participante
</span>
{#if canManage}
<button
type="button"
on:click={() => removeParticipant(p.user_id)}
class="p-1 rounded text-gray-300 hover:text-rose-500 hover:bg-rose-50 transition-colors opacity-0 group-hover:opacity-100"
title="Quitar participante"
>
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end">
<button type="button" on:click={() => dispatch('close')}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
Cerrar
</button>
</div>
</div>
</div>

View File

@@ -4,7 +4,9 @@
import { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api';
import { onMount } from 'svelte';
import ParticipantsModal from '$lib/components/ParticipantsModal.svelte';
let showParticipants = false;
let ticketId: string;
let ticket = null;
let comments = [];
@@ -374,6 +376,17 @@
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
</div>
<!-- Participantes -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">Asunto</h3>
<button type="button" on:click={() => showParticipants = true}
class="text-sm text-blue-600 hover:text-blue-700 font-medium">
Gestionar
</button>
</div>
</div>
<!-- SLA Information -->
{#if ticket.sla_response_due || ticket.sla_resolution_due}
<div class="pt-4 border-t border-gray-200">
@@ -429,4 +442,11 @@
</div>
</div>
{/if}
{#if showParticipants && ticket}
<ParticipantsModal
ticketId={ticket.id}
createdBy={ticket.created_by}
on:close={() => showParticipants = false}
/>
{/if}
</div>