Asunto en internos funcionando

This commit is contained in:
2026-03-24 08:01:57 -06:00
parent daf3a524c7
commit 08816ab73a
7 changed files with 660 additions and 240 deletions

View File

@@ -35,12 +35,25 @@ class IssueCreate(BaseModel):
return v.strip() return v.strip()
class IssueStatusUpdate(BaseModel):
status: str
@field_validator("status")
@classmethod
def validate_status(cls, v: str) -> str:
valid = {"OPEN", "IN_PROGRESS", "RESOLVED", "CLOSED"}
if v.upper() not in valid:
raise ValueError(f"status debe ser uno de: {valid}")
return v.upper()
class IssueResponse(BaseModel): class IssueResponse(BaseModel):
id: uuid.UUID id: uuid.UUID
ticket_id: uuid.UUID ticket_id: uuid.UUID
tenant_id: uuid.UUID tenant_id: uuid.UUID
content: str content: str
priority: str priority: str
status: str
created_by: uuid.UUID created_by: uuid.UUID
created_by_name: str created_by_name: str
tagged_users: List[TaggedUserBasic] = [] tagged_users: List[TaggedUserBasic] = []

View File

@@ -7,8 +7,8 @@ from sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import uuid import uuid
from app.models.issue import TicketIssue, ticket_issue_tagged_users from app.models.issue import TicketIssue, ticket_issue_tagged_users, IssueStatus
from app.api.schemas.issue import IssueCreate, IssueResponse from app.api.schemas.issue import IssueCreate, IssueResponse, IssueStatusUpdate
from app.core.database import get_db from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant from app.api.deps import get_current_user, get_current_tenant
@@ -571,6 +571,7 @@ async def get_ticket_issues(
IssueResponse( IssueResponse(
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id, id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
content=issue.content, priority=issue.priority.value, content=issue.content, priority=issue.priority.value,
status=issue.status.value,
created_by=issue.created_by, created_by=issue.created_by,
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}", created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
tagged_users=[ tagged_users=[
@@ -657,6 +658,7 @@ async def create_ticket_issue(
return IssueResponse( return IssueResponse(
id=new_issue.id, ticket_id=new_issue.ticket_id, tenant_id=new_issue.tenant_id, id=new_issue.id, ticket_id=new_issue.ticket_id, tenant_id=new_issue.tenant_id,
content=new_issue.content, priority=new_issue.priority.value, content=new_issue.content, priority=new_issue.priority.value,
status=new_issue.status.value,
created_by=new_issue.created_by, created_by=new_issue.created_by,
created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}", created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}",
tagged_users=[ tagged_users=[
@@ -710,3 +712,141 @@ async def upload_issue_attachment(
"attachment_filename": db_issue.attachment_filename, "attachment_filename": db_issue.attachment_filename,
"attachment_mime_type": db_issue.attachment_mime_type, "attachment_mime_type": db_issue.attachment_mime_type,
} }
@router.get("/{ticket_id}/issues/{issue_id}", response_model=IssueResponse)
async def get_issue_detail(
ticket_id: str,
issue_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Obtener detalle de un asunto. Accesible para todos los roles."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
issue_uuid = validate_uuid_param(issue_id, "issue ID")
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
result = await db.execute(query)
if not result.scalars().first():
raise HTTPException(status_code=404, detail="Ticket no encontrado")
result = await db.execute(
select(TicketIssue)
.where(TicketIssue.id == issue_uuid, TicketIssue.ticket_id == ticket_uuid)
.options(
selectinload(TicketIssue.created_by_user),
selectinload(TicketIssue.tagged_users),
)
)
issue = result.scalars().first()
if not issue:
raise HTTPException(status_code=404, detail="Asunto no encontrado")
return IssueResponse(
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
content=issue.content, priority=issue.priority.value,
status=issue.status.value,
created_by=issue.created_by,
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
tagged_users=[
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
for u in issue.tagged_users
],
attachment_filename=issue.attachment_filename,
attachment_mime_type=issue.attachment_mime_type,
created_at=issue.created_at, updated_at=issue.updated_at,
)
@router.patch("/{ticket_id}/issues/{issue_id}/status", response_model=IssueResponse)
async def update_issue_status(
ticket_id: str,
issue_id: str,
data: IssueStatusUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Actualizar status de un asunto."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
issue_uuid = validate_uuid_param(issue_id, "issue ID")
if current_user.role != UserRole.ADMIN and current_user.role != UserRole.CLIENT_ADMIN:
raise HTTPException(status_code=403, detail="Solo administradores pueden cambiar el status")
result = await db.execute(
select(TicketIssue)
.where(TicketIssue.id == issue_uuid, TicketIssue.ticket_id == ticket_uuid)
.options(
selectinload(TicketIssue.created_by_user),
selectinload(TicketIssue.tagged_users),
)
)
issue = result.scalars().first()
if not issue:
raise HTTPException(status_code=404, detail="Asunto no encontrado")
issue.status = IssueStatus[data.status]
issue.updated_at = datetime.utcnow()
await db.commit()
await safe_audit_log(
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.issue.status_update", resource_type="ticket_issue",
resource_id=issue.id,
new_values={"status": data.status},
)
return IssueResponse(
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
content=issue.content, priority=issue.priority.value,
status=issue.status.value,
created_by=issue.created_by,
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
tagged_users=[
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
for u in issue.tagged_users
],
attachment_filename=issue.attachment_filename,
attachment_mime_type=issue.attachment_mime_type,
created_at=issue.created_at, updated_at=issue.updated_at,
)
@router.delete("/{ticket_id}/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_issue(
ticket_id: str,
issue_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Eliminar un asunto. Solo ADMIN global o CLIENT_ADMIN."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
issue_uuid = validate_uuid_param(issue_id, "issue ID")
if current_user.role != UserRole.ADMIN and current_user.role != UserRole.CLIENT_ADMIN:
raise HTTPException(status_code=403, detail="Solo administradores pueden eliminar asuntos")
result = await db.execute(
select(TicketIssue).where(
TicketIssue.id == issue_uuid,
TicketIssue.ticket_id == ticket_uuid,
)
)
issue = result.scalars().first()
if not issue:
raise HTTPException(status_code=404, detail="Asunto no encontrado")
old_content = issue.content
old_status = issue.status.value
await db.delete(issue)
await db.commit()
await safe_audit_log(
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.issue.delete", resource_type="ticket_issue",
resource_id=issue_uuid,
old_values={"content": old_content, "status": old_status},
)

View File

@@ -63,6 +63,29 @@ async def read_users(
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() return result.scalars().all()
@router.get("/taggable", response_model=List[UserResponse])
async def get_taggable_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Obtener usuarios que se pueden etiquetar en asuntos.
- Staff interno + CLIENT_ADMIN → todos los usuarios activos
- CLIENT_USER → solo CLIENT_ADMIN de su tenant
"""
if current_user.role.is_global or current_user.role == UserRole.CLIENT_ADMIN:
query = select(User).where(User.is_active == True)
else:
query = select(User).where(
User.tenant_id == current_user.tenant_id,
User.role == UserRole.CLIENT_ADMIN,
User.is_active == True
)
query = query.order_by(User.first_name)
result = await db.execute(query)
return result.scalars().all()
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user( async def create_user(
@@ -402,3 +425,4 @@ async def activate_user(
await db.commit() await db.commit()
await db.refresh(db_user) await db.refresh(db_user)
return db_user return db_user

View File

@@ -8,6 +8,7 @@ from sqlalchemy import String, ForeignKey, Text, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
import enum
import uuid import uuid
from app.core.database import Base, GUID from app.core.database import Base, GUID
@@ -16,6 +17,13 @@ from sqlalchemy import Enum as SAEnum
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
class IssueStatus(str, enum.Enum):
OPEN = "OPEN"
IN_PROGRESS = "IN_PROGRESS"
RESOLVED = "RESOLVED"
CLOSED = "CLOSED"
# Tabla de relación N:M entre TicketIssue y User (usuarios etiquetados) # Tabla de relación N:M entre TicketIssue y User (usuarios etiquetados)
ticket_issue_tagged_users = Table( ticket_issue_tagged_users = Table(
"ticket_issue_tagged_users", "ticket_issue_tagged_users",
@@ -38,37 +46,23 @@ ticket_issue_tagged_users = Table(
class TicketIssue(Base): class TicketIssue(Base):
"""
Asunto de escalación de un ticket.
Solo puede crearlo:
- El usuario que creó el ticket (created_by del Ticket)
- El CLIENT_ADMIN del mismo tenant
"""
__tablename__ = "ticket_issues" __tablename__ = "ticket_issues"
# Multi-tenancy — siempre filtrar por esto
tenant_id: Mapped[uuid.UUID] = mapped_column( tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(), GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"), ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False, nullable=False,
) )
# Relación con el ticket padre
ticket_id: Mapped[uuid.UUID] = mapped_column( ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(), GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"), ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False, nullable=False,
) )
# Quién lo creó
created_by: Mapped[uuid.UUID] = mapped_column( created_by: Mapped[uuid.UUID] = mapped_column(
GUID(), GUID(),
ForeignKey("users.id"), ForeignKey("users.id"),
nullable=False, nullable=False,
) )
# Contenido
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
priority: Mapped[TicketPriority] = mapped_column( priority: Mapped[TicketPriority] = mapped_column(
@@ -80,23 +74,24 @@ class TicketIssue(Base):
nullable=False, nullable=False,
) )
# Adjunto opcional (reutiliza file_handler igual que TicketAttachment) status: Mapped[IssueStatus] = mapped_column(
SAEnum(IssueStatus, name="issue_status_enum", native_enum=False).with_variant(
PG_ENUM(IssueStatus, name="issue_status_enum", create_type=True),
"postgresql",
),
default=IssueStatus.OPEN,
nullable=False,
)
attachment_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) attachment_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
attachment_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) attachment_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
attachment_mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) attachment_mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Relationships
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="issues") ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="issues")
created_by_user: Mapped["User"] = relationship("User", foreign_keys=[created_by])
created_by_user: Mapped["User"] = relationship(
"User",
foreign_keys=[created_by],
)
tagged_users: Mapped[List["User"]] = relationship( tagged_users: Mapped[List["User"]] = relationship(
"User", "User", secondary=ticket_issue_tagged_users,
secondary=ticket_issue_tagged_users,
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<TicketIssue(id={self.id}, ticket={self.ticket_id}, priority={self.priority})>" return f"<TicketIssue(id={self.id}, ticket={self.ticket_id}, status={self.status})>"

View File

@@ -0,0 +1,41 @@
"""add_issue_status
Revision ID: 9158111a7e00
Revises: 0aec0a6e294a
Create Date: 2026-03-19 19:00:25.334583
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = '9158111a7e00'
down_revision = '0aec0a6e294a'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Verificar si el tipo ya existe antes de crearlo
op.execute("""
DO $$ BEGIN
CREATE TYPE issue_status_enum AS ENUM ('OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
""")
op.add_column('ticket_issues', sa.Column(
'status',
postgresql.ENUM('OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED',
name='issue_status_enum', create_type=False),
nullable=True
))
op.execute("UPDATE ticket_issues SET status = 'OPEN' WHERE status IS NULL")
op.alter_column('ticket_issues', 'status', nullable=False)
def downgrade() -> None:
op.drop_column('ticket_issues', 'status')
op.execute("DROP TYPE IF EXISTS issue_status_enum")

View File

@@ -0,0 +1,227 @@
<script lang="ts">
import { auth } from '$lib/stores/auth';
import { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api';
import { createEventDispatcher } from 'svelte';
export let issue: any;
export let ticketId: string;
const dispatch = createEventDispatcher();
const STATUSES = [
{ value: 'OPEN', label: 'Abierto', color: 'blue' },
{ value: 'IN_PROGRESS', label: 'En Progreso', color: 'indigo' },
{ value: 'RESOLVED', label: 'Resuelto', color: 'green' },
{ value: 'CLOSED', label: 'Cerrado', color: 'gray' }
];
const PRIORITIES = [
{ value: 'LOW', label: 'Baja', color: 'gray' },
{ value: 'MEDIUM', label: 'Media', color: 'blue' },
{ value: 'HIGH', label: 'Alta', color: 'orange' },
{ value: 'URGENT', label: 'Urgente', color: 'red' }
];
let isUpdatingStatus = false;
let isDeleting = false;
$: canManage = $auth.user?.role === 'ADMIN' || $auth.user?.role === 'CLIENT_ADMIN';
$: statusObj = STATUSES.find(s => s.value === issue.status) || STATUSES[0];
$: priorityObj = PRIORITIES.find(p => p.value === issue.priority) || PRIORITIES[1];
function formatDate(dateString: string) {
return new Date(dateString).toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
async function handleStatusChange(e: Event) {
const newStatus = (e.target as HTMLSelectElement).value;
isUpdatingStatus = true;
try {
const updated = await api.patch(`/tickets/${ticketId}/issues/${issue.id}/status`, {
status: newStatus
});
issue = updated;
toast.success('Estado actualizado');
dispatch('updated', updated);
} catch (err: any) {
toast.error(err.message || 'Error al actualizar estado');
} finally {
isUpdatingStatus = false;
}
}
async function handleDelete() {
if (!confirm('¿Eliminar este asunto? Esta acción no se puede deshacer.')) return;
isDeleting = true;
try {
await api.delete(`/tickets/${ticketId}/issues/${issue.id}`);
toast.success('Asunto eliminado');
dispatch('deleted', issue.id);
dispatch('close');
} catch (err: any) {
toast.error(err.message || 'Error al eliminar');
} finally {
isDeleting = false;
}
}
function handleClose() {
dispatch('close');
}
</script>
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75"
on:click={handleClose}
role="button"
tabindex="-1"
on:keydown={e => e.key === 'Escape' && handleClose()}
/>
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg z-10">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Detalle del Asunto</h3>
<button type="button" on:click={handleClose} class="text-gray-400 hover:text-gray-500">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-5 space-y-4">
<!-- Status + Priority -->
<div class="flex items-center gap-3">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
bg-{priorityObj.color}-100 text-{priorityObj.color}-800"
>
{priorityObj.label}
</span>
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
bg-{statusObj.color}-100 text-{statusObj.color}-800"
>
{statusObj.label}
</span>
</div>
<!-- Contenido -->
<div>
<p class="text-sm font-medium text-gray-500 mb-1">Descripción</p>
<p class="text-sm text-gray-900 whitespace-pre-wrap bg-gray-50 rounded-md p-3">
{issue.content}
</p>
</div>
<!-- Creado por / cuando -->
<div class="flex justify-between text-sm text-gray-500">
<span
>Creado por <span class="font-medium text-gray-700">{issue.created_by_name}</span></span
>
<span>{formatDate(issue.created_at)}</span>
</div>
<!-- Usuarios etiquetados -->
{#if issue.tagged_users?.length > 0}
<div>
<p class="text-sm font-medium text-gray-500 mb-2">Usuarios etiquetados</p>
<div class="flex flex-wrap gap-2">
{#each issue.tagged_users as user}
<div
class="flex items-center gap-1.5 bg-blue-50 text-blue-700 px-2.5 py-1 rounded-full text-xs"
>
<span class="font-medium">{user.full_name}</span>
<span class="text-blue-400">·</span>
<span>{user.email}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Adjunto -->
{#if issue.attachment_filename}
<div>
<p class="text-sm font-medium text-gray-500 mb-1">Adjunto</p>
<div class="flex items-center gap-2 bg-gray-50 rounded-md p-2">
<svg
class="h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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-sm text-gray-700">{issue.attachment_filename}</span>
</div>
</div>
{/if}
<!-- Cambiar status (solo admins) -->
{#if canManage}
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Cambiar estado</label>
<select
value={issue.status}
on:change={handleStatusChange}
disabled={isUpdatingStatus}
class="block w-full rounded-md border border-gray-300 shadow-sm
focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2
disabled:opacity-50"
>
{#each STATUSES as s}
<option value={s.value}>{s.label}</option>
{/each}
</select>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-between px-6 py-4 border-t border-gray-200">
{#if canManage}
<button
type="button"
on:click={handleDelete}
disabled={isDeleting}
class="px-4 py-2 text-sm font-medium text-red-700 bg-red-50 border
border-red-200 rounded-md hover:bg-red-100 disabled:opacity-50"
>
{isDeleting ? 'Eliminando...' : 'Eliminar asunto'}
</button>
{:else}
<div />
{/if}
<button
type="button"
on:click={handleClose}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border
border-gray-300 rounded-md shadow-sm hover:bg-gray-50"
>
Cerrar
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import IssueDetailModal from '$lib/components/IssueDetailModal.svelte';
import IssueModal from '$lib/components/IssueModal.svelte'; import IssueModal from '$lib/components/IssueModal.svelte';
import ParticipantsModal from '$lib/components/ParticipantsModal.svelte'; import ParticipantsModal from '$lib/components/ParticipantsModal.svelte';
import { toast } from '$lib/stores/toast'; import { toast } from '$lib/stores/toast';
@@ -9,6 +10,7 @@
let showParticipants = false; let showParticipants = false;
let showIssueModal = false; let showIssueModal = false;
let selectedIssue = null;
let issues = []; let issues = [];
let ticketId: string; let ticketId: string;
let ticket = null; let ticket = null;
@@ -41,7 +43,6 @@
const commentsData = await api.get(`/tickets/${ticketId}/comments`); const commentsData = await api.get(`/tickets/${ticketId}/comments`);
comments = commentsData; comments = commentsData;
} catch (e) { } catch (e) {
// No mostrar error en polling silencioso
console.error('Error recargando comentarios:', e); console.error('Error recargando comentarios:', e);
} }
} }
@@ -53,7 +54,7 @@
api.get(`/tickets/${ticketId}`), api.get(`/tickets/${ticketId}`),
api.get(`/tickets/${ticketId}/comments`), api.get(`/tickets/${ticketId}/comments`),
api.get(`/tickets/${ticketId}/attachments`), api.get(`/tickets/${ticketId}/attachments`),
api.get('/users/'), api.get('/users/taggable'),
api.get(`/tickets/${ticketId}/issues`) api.get(`/tickets/${ticketId}/issues`)
]); ]);
ticket = ticketData; ticket = ticketData;
@@ -66,18 +67,15 @@
} finally { } finally {
isLoading = false; isLoading = false;
} }
} }
async function handleAddComment() { async function handleAddComment() {
if (!newComment.trim()) return; if (!newComment.trim()) return;
isSubmittingComment = true; isSubmittingComment = true;
try { try {
const comment = await api.post(`/tickets/${ticketId}/issues`, { const comment = await api.post(`/tickets/${ticketId}/comments`, {
content: content.trim(), content: newComment.trim(),
priority, is_internal: false
tagged_user_ids: taggedUserIds
}); });
comments = [...comments, comment]; comments = [...comments, comment];
newComment = ''; newComment = '';
@@ -125,7 +123,6 @@
); );
toast.success('Descarga iniciada'); toast.success('Descarga iniciada');
} catch (error) { } catch (error) {
console.error('Download error:', error);
toast.error('Error al descargar el archivo'); toast.error('Error al descargar el archivo');
} }
} }
@@ -134,17 +131,12 @@
ticketId = $page.params.id; ticketId = $page.params.id;
if (ticketId) { if (ticketId) {
loadData(); loadData();
// Polling cada 3 segundos
pollingInterval = setInterval(() => { pollingInterval = setInterval(() => {
loadComments(); loadComments();
}, 3000); }, 3000);
} }
// Cleanup cuando se desmonte el componente
return () => { return () => {
if (pollingInterval) { if (pollingInterval) clearInterval(pollingInterval);
clearInterval(pollingInterval);
}
}; };
}); });
</script> </script>
@@ -194,7 +186,6 @@
</span> </span>
</div> </div>
</div> </div>
<button <button
on:click={() => goto('/tickets')} on:click={() => goto('/tickets')}
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
@@ -203,13 +194,8 @@
</button> </button>
</div> </div>
</div> </div>
<div class="px-6 py-5"> <div class="px-6 py-5">
<div class="prose max-w-none"> <p class="whitespace-pre-wrap text-gray-700">{ticket.description}</p>
<p class="whitespace-pre-wrap text-gray-700">
{ticket.description}
</p>
</div>
</div> </div>
</div> </div>
@@ -221,8 +207,7 @@
Archivos Adjuntos ({attachments.length}) Archivos Adjuntos ({attachments.length})
</h3> </h3>
</div> </div>
<div class="px-6 py-5"> <div class="px-6 py-5 space-y-2">
<div class="space-y-2">
{#each attachments as attachment} {#each attachments as attachment}
<div <div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors" class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
@@ -239,19 +224,13 @@
</p> </p>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB {Math.round(attachment.size_bytes / 1024)} KB
{#if attachment.uploaded_by_name} {#if attachment.uploaded_by_name}• Subido por {attachment.uploaded_by_name}{/if}
• Subido por {attachment.uploaded_by_name}
{/if}
{#if attachment.uploaded_at}
{formatDate(attachment.uploaded_at)}
{/if}
</p> </p>
</div> </div>
</div> </div>
<button <button
on:click={() => handleDownloadAttachment(attachment)} on:click={() => handleDownloadAttachment(attachment)}
class="inline-flex items-center px-2 py-2 text-sm font-medium text-blue-700 hover:bg-blue-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"
title="Descargar {attachment.original_filename}"
> >
Descargar Descargar
</button> </button>
@@ -259,7 +238,6 @@
{/each} {/each}
</div> </div>
</div> </div>
</div>
{/if} {/if}
<!-- Comments Section --> <!-- Comments Section -->
@@ -269,9 +247,7 @@
</div> </div>
<div class="px-6 py-5"> <div class="px-6 py-5">
{#if comments.length === 0} {#if comments.length === 0}
<p class="text-gray-500 text-center py-4"> <p class="text-gray-500 text-center py-4">No hay comentarios aún.</p>
No hay comentarios aún. ¡Sé el primero en comentar!
</p>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
{#each comments as comment} {#each comments as comment}
@@ -281,39 +257,31 @@
> >
<span class="text-blue-700 text-xs font-medium"> <span class="text-blue-700 text-xs font-medium">
{comment.author_name {comment.author_name
? comment.author_name ?.split(' ')
.split(' ')
.map(n => n[0]) .map(n => n[0])
.join('') .join('') ?? 'U'}
: 'U'}
</span> </span>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1"> <div class="flex items-center space-x-2 mb-1">
<span class="text-sm font-medium text-gray-900"> <span class="text-sm font-medium text-gray-900"
{comment.author_name || 'Usuario Desconocido'} >{comment.author_name || 'Desconocido'}</span
</span> >
<span class="text-xs text-gray-500"> <span class="text-xs text-gray-500">{formatDate(comment.created_at)}</span>
{formatDate(comment.created_at)}
</span>
{#if comment.is_internal} {#if comment.is_internal}
<span <span
class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200" class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200"
>Interno</span
> >
Interno
</span>
{/if} {/if}
</div> </div>
<p class="text-gray-700 whitespace-pre-wrap"> <p class="text-gray-700 whitespace-pre-wrap">{comment.content}</p>
{comment.content}
</p>
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Add Comment Form -->
<div class="mt-6 pt-6 border-t border-gray-200"> <div class="mt-6 pt-6 border-t border-gray-200">
<form on:submit|preventDefault={handleAddComment} class="space-y-4"> <form on:submit|preventDefault={handleAddComment} class="space-y-4">
<textarea <textarea
@@ -323,11 +291,10 @@
bind:value={newComment} bind:value={newComment}
disabled={isSubmittingComment} disabled={isSubmittingComment}
/> />
<div class="flex justify-end"> <div class="flex justify-end">
<button <button
type="submit" 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-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-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 disabled:opacity-50"
disabled={isSubmittingComment || !newComment.trim()} disabled={isSubmittingComment || !newComment.trim()}
> >
{#if isSubmittingComment} {#if isSubmittingComment}
@@ -362,30 +329,28 @@
#{ticket.ticket_number || ticket.id.substring(0, 8)} #{ticket.ticket_number || ticket.id.substring(0, 8)}
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Creado por</dt> <dt class="text-sm font-medium text-gray-500">Creado por</dt>
<dd class="text-sm text-gray-900">{getUserName(ticket.created_by)}</dd> <dd class="text-sm text-gray-900">{getUserName(ticket.created_by)}</dd>
</div> </div>
{#if ticket.assigned_to} {#if ticket.assigned_to}
<div> <div>
<dt class="text-sm font-medium text-gray-500">Asignado a</dt> <dt class="text-sm font-medium text-gray-500">Asignado a</dt>
<dd class="text-sm text-gray-900">{getUserName(ticket.assigned_to)}</dd> <dd class="text-sm text-gray-900">{getUserName(ticket.assigned_to)}</dd>
</div> </div>
{/if} {/if}
<div> <div>
<dt class="text-sm font-medium text-gray-500">Creado</dt> <dt class="text-sm font-medium text-gray-500">Creado</dt>
<dd class="text-sm text-gray-900">{formatDate(ticket.created_at)}</dd> <dd class="text-sm text-gray-900">{formatDate(ticket.created_at)}</dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Última actualización</dt> <dt class="text-sm font-medium text-gray-500">Última actualización</dt>
<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>
</div>
</div>
<!-- Participantes --> <!-- Asuntos -->
<div class="bg-white shadow rounded-lg"> <div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200 flex justify-between items-center"> <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"> <h3 class="text-lg font-semibold text-gray-900">
@@ -398,15 +363,19 @@
on:click={() => (showIssueModal = true)} on:click={() => (showIssueModal = true)}
class="text-sm text-blue-600 hover:text-blue-700 font-medium" class="text-sm text-blue-600 hover:text-blue-700 font-medium"
> >
Crear asunto Crear Asunto
</button> </button>
</div> </div>
{#if issues.length > 0} {#if issues.length > 0}
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{#each issues as issue} {#each issues as issue}
<div class="px-6 py-4"> <button
type="button"
on:click={() => (selectedIssue = issue)}
class="w-full text-left px-6 py-4 hover:bg-gray-50 transition-colors"
>
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<p class="text-sm text-gray-700 flex-1">{issue.content}</p> <p class="text-sm text-gray-700 flex-1 line-clamp-2">{issue.content}</p>
<span <span
class="text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 shrink-0" class="text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 shrink-0"
> >
@@ -414,16 +383,16 @@
</span> </span>
</div> </div>
{#if issue.tagged_users?.length > 0} {#if issue.tagged_users?.length > 0}
<div class="mt-2 flex flex-wrap gap-1"> <div class="mt-1 flex flex-wrap gap-1">
{#each issue.tagged_users as u} {#each issue.tagged_users as u}
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full"> <span class="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full"
{u.full_name} >{u.full_name}</span
</span> >
{/each} {/each}
</div> </div>
{/if} {/if}
<p class="text-xs text-gray-400 mt-1">{issue.created_by_name}</p> <p class="text-xs text-gray-400 mt-1">{issue.created_by_name}</p>
</div> </button>
{/each} {/each}
</div> </div>
{:else} {:else}
@@ -433,39 +402,35 @@
<!-- 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="bg-white shadow rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 mb-3"> <div class="px-6 py-5 border-b border-gray-200">
SLA (Acuerdos de Nivel de Servicio) <h3 class="text-sm font-semibold text-gray-900">SLA</h3>
</h4> </div>
<div class="px-6 py-5 space-y-3">
{#if ticket.sla_response_due} {#if ticket.sla_response_due}
<div class="mb-3"> <div>
<dt class="text-xs font-medium text-gray-500">Tiempo de Respuesta</dt> <dt class="text-xs font-medium text-gray-500">Tiempo de Respuesta</dt>
<dd class="text-sm text-gray-900 mt-1"> <dd class="text-sm text-gray-900 mt-1">
{formatDate(ticket.sla_response_due)} {formatDate(ticket.sla_response_due)}
{#if new Date(ticket.sla_response_due) < new Date() && !ticket.sla_response_met} {#if new Date(ticket.sla_response_due) < new Date() && !ticket.sla_response_met}
<span <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" 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
> >
Vencido
</span>
{:else if ticket.sla_response_met} {:else if ticket.sla_response_met}
<span <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" 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
> >
Cumplido
</span>
{:else} {:else}
<span <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" 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
> >
En plazo
</span>
{/if} {/if}
</dd> </dd>
</div> </div>
{/if} {/if}
{#if ticket.sla_resolution_due} {#if ticket.sla_resolution_due}
<div> <div>
<dt class="text-xs font-medium text-gray-500">Tiempo de Resolución</dt> <dt class="text-xs font-medium text-gray-500">Tiempo de Resolución</dt>
@@ -474,32 +439,30 @@
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met} {#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met}
<span <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" 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
> >
Vencido
</span>
{:else if ticket.sla_resolution_met} {:else if ticket.sla_resolution_met}
<span <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" 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
> >
Cumplido
</span>
{:else} {:else}
<span <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" 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
> >
En plazo
</span>
{/if} {/if}
</dd> </dd>
</div> </div>
{/if} {/if}
</div> </div>
</div>
{/if} {/if}
</div> </div>
</div> </div>
</div>
</div>
{/if} {/if}
<!-- Modals -->
{#if showParticipants && ticket} {#if showParticipants && ticket}
<ParticipantsModal <ParticipantsModal
ticketId={ticket.id} ticketId={ticket.id}
@@ -507,6 +470,7 @@
on:close={() => (showParticipants = false)} on:close={() => (showParticipants = false)}
/> />
{/if} {/if}
{#if showIssueModal && ticket} {#if showIssueModal && ticket}
<IssueModal <IssueModal
ticketId={ticket.id} ticketId={ticket.id}
@@ -518,4 +482,20 @@
}} }}
/> />
{/if} {/if}
{#if selectedIssue && ticket}
<IssueDetailModal
issue={selectedIssue}
ticketId={ticket.id}
on:close={() => (selectedIssue = null)}
on:updated={e => {
issues = issues.map(i => (i.id === e.detail.id ? e.detail : i));
selectedIssue = e.detail;
}}
on:deleted={e => {
issues = issues.filter(i => i.id !== e.detail);
selectedIssue = null;
}}
/>
{/if}
</div> </div>