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

48
add_participant.py Normal file
View File

@@ -0,0 +1,48 @@
import asyncio
from app.core.database import AsyncSessionLocal, Base, engine
from app.models.ticket import Ticket
from sqlalchemy import String, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import GUID
import uuid
# Agregar modelo al archivo ticket.py
new_model = '''
class TicketParticipant(Base):
"""Participantes asignados a un ticket"""
__tablename__ = "ticket_participants"
ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
user: Mapped["User"] = relationship("User")
__table_args__ = (
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
)
def __repr__(self):
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
'''
with open("/app/app/models/ticket.py", "r") as f:
content = f.read()
if "TicketParticipant" in content:
print("Ya existe TicketParticipant")
else:
content += new_model
with open("/app/app/models/ticket.py", "w") as f:
f.write(content)
print("OK: TicketParticipant agregado")

21
add_relation.py Normal file
View File

@@ -0,0 +1,21 @@
with open("/app/app/models/ticket.py", "r") as f:
content = f.read()
old = ' def __repr__(self) -> str:\n return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'
new = ''' participants: Mapped[list["TicketParticipant"]] = relationship(
"TicketParticipant",
back_populates="ticket",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'''
if old in content:
content = content.replace(old, new)
print("OK: relacion participants agregada")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/models/ticket.py", "w") as f:
f.write(content)

View File

@@ -0,0 +1,48 @@
import asyncio
from app.core.database import AsyncSessionLocal, Base, engine
from app.models.ticket import Ticket
from sqlalchemy import String, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import GUID
import uuid
# Agregar modelo al archivo ticket.py
new_model = '''
class TicketParticipant(Base):
"""Participantes asignados a un ticket"""
__tablename__ = "ticket_participants"
ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
user: Mapped["User"] = relationship("User")
__table_args__ = (
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
)
def __repr__(self):
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
'''
with open("/app/app/models/ticket.py", "r") as f:
content = f.read()
if "TicketParticipant" in content:
print("Ya existe TicketParticipant")
else:
content += new_model
with open("/app/app/models/ticket.py", "w") as f:
f.write(content)
print("OK: TicketParticipant agregado")

21
backend/add_relation.py Normal file
View File

@@ -0,0 +1,21 @@
with open("/app/app/models/ticket.py", "r") as f:
content = f.read()
old = ' def __repr__(self) -> str:\n return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'
new = ''' participants: Mapped[list["TicketParticipant"]] = relationship(
"TicketParticipant",
back_populates="ticket",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'''
if old in content:
content = content.replace(old, new)
print("OK: relacion participants agregada")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/models/ticket.py", "w") as f:
f.write(content)

View File

@@ -11,13 +11,14 @@ import uuid
class CategoryCreate(BaseModel): class CategoryCreate(BaseModel):
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente).""" """Schema para crear categoría. tenant_id opcional para ADMIN global."""
name: str name: str
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
sla_response_hours: int = 24 sla_response_hours: int = 24
sla_resolution_hours: int = 72 sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None auto_assign_to: Optional[uuid.UUID] = None
tenant_id: Optional[uuid.UUID] = None
class CategoryUpdate(BaseModel): class CategoryUpdate(BaseModel):

View File

@@ -79,11 +79,16 @@ async def create_category(
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario. ✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
""" """
# ✅ CORREGIDO: Asignar tenant_id del usuario actual # ADMIN global puede especificar tenant_id; otros usan el suyo
db_category = Category( from app.models.user import UserRole as _R
**category.model_dump(), data = category.model_dump()
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático if current_user.role == _R.ADMIN and data.get("tenant_id"):
) target_tenant_id = data["tenant_id"]
else:
target_tenant_id = current_user.tenant_id
data["tenant_id"] = target_tenant_id
db_category = Category(**data)
db.add(db_category) db.add(db_category)
await db.commit() await db.commit()

View File

@@ -0,0 +1,130 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import uuid
from app.api import deps
from app.core.database import get_db
from app.models.ticket import Ticket, TicketParticipant
from app.models.user import User
from pydantic import BaseModel
router = APIRouter()
class ParticipantAdd(BaseModel):
user_id: uuid.UUID
class ParticipantResponse(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
ticket_id: uuid.UUID
role: str
user_email: str
user_name: str
class Config:
from_attributes = True
@router.get("/{ticket_id}/participants", response_model=list[ParticipantResponse])
async def list_participants(
ticket_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
result = await db.execute(
select(TicketParticipant)
.where(TicketParticipant.ticket_id == ticket_id)
.options(selectinload(TicketParticipant.user))
)
participants = result.scalars().all()
return [
ParticipantResponse(
id=p.id,
user_id=p.user_id,
ticket_id=p.ticket_id,
role=p.role,
user_email=p.user.email,
user_name=f"{p.user.first_name or ''} {p.user.last_name or ''}".strip()
)
for p in participants
]
@router.post("/{ticket_id}/participants", response_model=ParticipantResponse)
async def add_participant(
ticket_id: uuid.UUID,
data: ParticipantAdd,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
# Verificar que el ticket existe
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = ticket_result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Solo el creador puede agregar participantes
if ticket.created_by != current_user.id and not current_user.role.is_global:
raise HTTPException(status_code=403, detail="Only the ticket creator can add participants")
# Verificar que el usuario existe
user_result = await db.execute(select(User).where(User.id == data.user_id))
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verificar que no sea duplicado
existing = await db.execute(
select(TicketParticipant).where(
TicketParticipant.ticket_id == ticket_id,
TicketParticipant.user_id == data.user_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="User is already a participant")
participant = TicketParticipant(
ticket_id=ticket_id,
user_id=data.user_id,
role="participant"
)
db.add(participant)
await db.commit()
await db.refresh(participant)
return ParticipantResponse(
id=participant.id,
user_id=participant.user_id,
ticket_id=participant.ticket_id,
role=participant.role,
user_email=user.email,
user_name=f"{user.first_name or ''} {user.last_name or ''}".strip()
)
@router.delete("/{ticket_id}/participants/{user_id}", status_code=204)
async def remove_participant(
ticket_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = ticket_result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
if ticket.created_by != current_user.id and not current_user.role.is_global:
raise HTTPException(status_code=403, detail="Only the ticket creator can remove participants")
result = await db.execute(
select(TicketParticipant).where(
TicketParticipant.ticket_id == ticket_id,
TicketParticipant.user_id == user_id
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
await db.delete(participant)
await db.commit()

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants
api_router = APIRouter() api_router = APIRouter()
@@ -53,6 +53,12 @@ api_router.include_router(
prefix="/tickets", prefix="/tickets",
tags=["tickets"] tags=["tickets"]
) )
# Participants routes (nested under tickets)
api_router.include_router(
participants.router,
prefix="/tickets",
tags=["participants"]
)
# Client Profile routes # Client Profile routes
api_router.include_router( api_router.include_router(

View File

@@ -157,5 +157,37 @@ class Ticket(Base):
CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'), CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'),
) )
participants: Mapped[list["TicketParticipant"]] = relationship(
"TicketParticipant",
back_populates="ticket",
cascade="all, delete-orphan"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>" return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
class TicketParticipant(Base):
"""Participantes asignados a un ticket"""
__tablename__ = "ticket_participants"
ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
user: Mapped["User"] = relationship("User")
__table_args__ = (
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
)
def __repr__(self):
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"

3
backend/check_schema.py Normal file
View File

@@ -0,0 +1,3 @@
with open("/app/app/api/schemas/category.py", "r") as f:
content = f.read()
print(content)

28
backend/fix_categories.py Normal file
View File

@@ -0,0 +1,28 @@
with open("/app/app/api/v1/endpoints/categories.py", "r") as f:
content = f.read()
old = ''' # ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_category = Category(
**category.model_dump(),
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)'''
new = ''' # ADMIN global puede especificar tenant_id; otros usan el suyo
from app.models.user import UserRole as _R
data = category.model_dump()
if current_user.role == _R.ADMIN and data.get("tenant_id"):
target_tenant_id = data["tenant_id"]
else:
target_tenant_id = current_user.tenant_id
data["tenant_id"] = target_tenant_id
db_category = Category(**data)'''
if old in content:
content = content.replace(old, new)
print("OK: fix aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/categories.py", "w") as f:
f.write(content)

34
backend/fix_router.py Normal file
View File

@@ -0,0 +1,34 @@
with open("/app/app/api/v1/router.py", "r") as f:
content = f.read()
old = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports"
new = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants"
old2 = "# Tickets routes\napi_router.include_router(\n tickets.router,\n prefix=\"/tickets\",\n tags=[\"tickets\"]\n)"
new2 = """# Tickets routes
api_router.include_router(
tickets.router,
prefix="/tickets",
tags=["tickets"]
)
# Participants routes (nested under tickets)
api_router.include_router(
participants.router,
prefix="/tickets",
tags=["participants"]
)"""
if old in content:
content = content.replace(old, new)
print("OK: import agregado")
else:
print("ERROR: import no encontrado")
if old2 in content:
content = content.replace(old2, new2)
print("OK: router agregado")
else:
print("ERROR: router no encontrado")
with open("/app/app/api/v1/router.py", "w") as f:
f.write(content)

30
backend/fix_schema.py Normal file
View File

@@ -0,0 +1,30 @@
with open("/app/app/api/schemas/category.py", "r") as f:
content = f.read()
old = '''class CategoryCreate(BaseModel):
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None'''
new = '''class CategoryCreate(BaseModel):
"""Schema para crear categoría. tenant_id opcional para ADMIN global."""
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None
tenant_id: Optional[uuid.UUID] = None'''
if old in content:
content = content.replace(old, new)
print("OK")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/schemas/category.py", "w") as f:
f.write(content)

View File

@@ -0,0 +1,28 @@
"""add_ticket_participants
Revision ID: 46bccd948688
Revises: d2e3f4a5b6c7
Create Date: 2026-03-11 17:59:55.409878
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '46bccd948688'
down_revision = 'd2e3f4a5b6c7'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

3
check_schema.py Normal file
View File

@@ -0,0 +1,3 @@
with open("/app/app/api/schemas/category.py", "r") as f:
content = f.read()
print(content)

28
fix_categories.py Normal file
View File

@@ -0,0 +1,28 @@
with open("/app/app/api/v1/endpoints/categories.py", "r") as f:
content = f.read()
old = ''' # ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_category = Category(
**category.model_dump(),
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)'''
new = ''' # ADMIN global puede especificar tenant_id; otros usan el suyo
from app.models.user import UserRole as _R
data = category.model_dump()
if current_user.role == _R.ADMIN and data.get("tenant_id"):
target_tenant_id = data["tenant_id"]
else:
target_tenant_id = current_user.tenant_id
data["tenant_id"] = target_tenant_id
db_category = Category(**data)'''
if old in content:
content = content.replace(old, new)
print("OK: fix aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/categories.py", "w") as f:
f.write(content)

34
fix_router.py Normal file
View File

@@ -0,0 +1,34 @@
with open("/app/app/api/v1/router.py", "r") as f:
content = f.read()
old = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports"
new = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants"
old2 = "# Tickets routes\napi_router.include_router(\n tickets.router,\n prefix=\"/tickets\",\n tags=[\"tickets\"]\n)"
new2 = """# Tickets routes
api_router.include_router(
tickets.router,
prefix="/tickets",
tags=["tickets"]
)
# Participants routes (nested under tickets)
api_router.include_router(
participants.router,
prefix="/tickets",
tags=["participants"]
)"""
if old in content:
content = content.replace(old, new)
print("OK: import agregado")
else:
print("ERROR: import no encontrado")
if old2 in content:
content = content.replace(old2, new2)
print("OK: router agregado")
else:
print("ERROR: router no encontrado")
with open("/app/app/api/v1/router.py", "w") as f:
f.write(content)

30
fix_schema.py Normal file
View File

@@ -0,0 +1,30 @@
with open("/app/app/api/schemas/category.py", "r") as f:
content = f.read()
old = '''class CategoryCreate(BaseModel):
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None'''
new = '''class CategoryCreate(BaseModel):
"""Schema para crear categoría. tenant_id opcional para ADMIN global."""
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None
tenant_id: Optional[uuid.UUID] = None'''
if old in content:
content = content.replace(old, new)
print("OK")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/schemas/category.py", "w") as f:
f.write(content)

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

@@ -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 { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api'; import { api } from '$lib/utils/api';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import ParticipantsModal from '$lib/components/ParticipantsModal.svelte';
let showParticipants = false;
let ticketId: string; let ticketId: string;
let ticket = null; let ticket = null;
let comments = []; let comments = [];
@@ -374,6 +376,17 @@
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd> <dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
</div> </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 --> <!-- SLA Information -->
{#if ticket.sla_response_due || ticket.sla_resolution_due} {#if ticket.sla_response_due || ticket.sla_resolution_due}
<div class="pt-4 border-t border-gray-200"> <div class="pt-4 border-t border-gray-200">
@@ -429,4 +442,11 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if showParticipants && ticket}
<ParticipantsModal
ticketId={ticket.id}
createdBy={ticket.created_by}
on:close={() => showParticipants = false}
/>
{/if}
</div> </div>

130
participants_router.py Normal file
View File

@@ -0,0 +1,130 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import uuid
from app.api import deps
from app.core.database import get_db
from app.models.ticket import Ticket, TicketParticipant
from app.models.user import User
from pydantic import BaseModel
router = APIRouter()
class ParticipantAdd(BaseModel):
user_id: uuid.UUID
class ParticipantResponse(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
ticket_id: uuid.UUID
role: str
user_email: str
user_name: str
class Config:
from_attributes = True
@router.get("/{ticket_id}/participants", response_model=list[ParticipantResponse])
async def list_participants(
ticket_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
result = await db.execute(
select(TicketParticipant)
.where(TicketParticipant.ticket_id == ticket_id)
.options(selectinload(TicketParticipant.user))
)
participants = result.scalars().all()
return [
ParticipantResponse(
id=p.id,
user_id=p.user_id,
ticket_id=p.ticket_id,
role=p.role,
user_email=p.user.email,
user_name=f"{p.user.first_name or ''} {p.user.last_name or ''}".strip()
)
for p in participants
]
@router.post("/{ticket_id}/participants", response_model=ParticipantResponse)
async def add_participant(
ticket_id: uuid.UUID,
data: ParticipantAdd,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
# Verificar que el ticket existe
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = ticket_result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Solo el creador puede agregar participantes
if ticket.created_by != current_user.id and not current_user.role.is_global:
raise HTTPException(status_code=403, detail="Only the ticket creator can add participants")
# Verificar que el usuario existe
user_result = await db.execute(select(User).where(User.id == data.user_id))
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verificar que no sea duplicado
existing = await db.execute(
select(TicketParticipant).where(
TicketParticipant.ticket_id == ticket_id,
TicketParticipant.user_id == data.user_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="User is already a participant")
participant = TicketParticipant(
ticket_id=ticket_id,
user_id=data.user_id,
role="participant"
)
db.add(participant)
await db.commit()
await db.refresh(participant)
return ParticipantResponse(
id=participant.id,
user_id=participant.user_id,
ticket_id=participant.ticket_id,
role=participant.role,
user_email=user.email,
user_name=f"{user.first_name or ''} {user.last_name or ''}".strip()
)
@router.delete("/{ticket_id}/participants/{user_id}", status_code=204)
async def remove_participant(
ticket_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = ticket_result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
if ticket.created_by != current_user.id and not current_user.role.is_global:
raise HTTPException(status_code=403, detail="Only the ticket creator can remove participants")
result = await db.execute(
select(TicketParticipant).where(
TicketParticipant.ticket_id == ticket_id,
TicketParticipant.user_id == user_id
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
await db.delete(participant)
await db.commit()