Permisos en progreso

This commit is contained in:
2026-03-17 13:49:23 -06:00
parent b67a384923
commit 80d41c9487
9 changed files with 913 additions and 507 deletions

View File

@@ -28,6 +28,9 @@ async def create_tenant(
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
from app.models.permission import TenantPermission, DEFAULT_PERMISSIONS
from datetime import datetime
# Check existing slug
query = select(Tenant).where(Tenant.slug == tenant.slug)
result = await db.execute(query)
@@ -38,6 +41,23 @@ async def create_tenant(
data['slug'] = data['slug'].lower().strip()
db_tenant = Tenant(**data)
db.add(db_tenant)
await db.flush() # genera el id sin hacer commit
# Inicializar permisos por defecto para el nuevo tenant
now = datetime.utcnow()
for role, perms in DEFAULT_PERMISSIONS.items():
for permission, granted in perms.items():
db.add(TenantPermission(
id=uuid.uuid4(),
tenant_id=db_tenant.id,
user_id=None,
role=role,
permission=permission,
granted=granted,
created_at=now,
updated_at=now,
))
await db.commit()
await db.refresh(db_tenant)
return db_tenant

View File

@@ -11,6 +11,7 @@ from .client_profile import ClientProfile
from .attachment import TicketAttachment
from .audit import AuditLog
from .refresh_token import RefreshToken
from .permission import TenantPermission
__all__ = [
"User",
@@ -18,6 +19,7 @@ __all__ = [
"Ticket",
"TicketIssue",
"TicketComment",
"TenantPermission",
"System",
"Category",
"ClientProfile",

View File

@@ -0,0 +1,67 @@
"""
TenantPermission Model - ServiceManagerWeb
Permisos dinámicos por tenant.
- Si user_id es None → aplica al rol completo (default del tenant)
- Si user_id tiene valor → override individual para ese usuario
"""
from sqlalchemy import String, Boolean, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional
import uuid
from app.core.database import Base, GUID
CLIENT_PERMISSIONS = [
"view_tickets",
"create_tickets",
"close_tickets",
"view_reports",
"manage_tenant_users",
"create_issues",
]
DEFAULT_PERMISSIONS = {
"CLIENT_ADMIN": {p: True for p in CLIENT_PERMISSIONS},
"CLIENT_USER": {
"view_tickets": True,
"create_tickets": True,
"close_tickets": False,
"view_reports": False,
"manage_tenant_users": False,
"create_issues": False,
}
}
class TenantPermission(Base):
__tablename__ = "tenant_permissions"
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
)
role: Mapped[str] = mapped_column(String(50), nullable=False)
permission: Mapped[str] = mapped_column(String(100), nullable=False)
granted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
tenant: Mapped["Tenant"] = relationship("Tenant")
user: Mapped[Optional["User"]] = relationship("User")
__table_args__ = (
UniqueConstraint(
"tenant_id", "role", "user_id", "permission",
name="uq_tenant_permission"
),
)
def __repr__(self) -> str:
scope = f"user:{self.user_id}" if self.user_id else f"role:{self.role}"
return f"<TenantPermission({scope} {self.permission}={'' if self.granted else ''})>"

View File

@@ -0,0 +1,282 @@
"""add_tenant_permissions
Revision ID: 0aec0a6e294a
Revises: b2dcb926e091
Create Date: 2026-03-17 19:33:30.229993
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0aec0a6e294a'
down_revision = 'b2dcb926e091'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_email_templates_tenant_id', table_name='email_templates')
op.drop_table('email_templates')
op.drop_index('idx_ticket_status_history_changed_by', table_name='ticket_status_history')
op.drop_index('idx_ticket_status_history_created_at', table_name='ticket_status_history')
op.drop_index('idx_ticket_status_history_ticket_id', table_name='ticket_status_history')
op.drop_table('ticket_status_history')
op.drop_index('idx_notification_logs_created_at', table_name='notification_logs')
op.drop_index('idx_notification_logs_recipient', table_name='notification_logs')
op.drop_index('idx_notification_logs_status', table_name='notification_logs')
op.drop_index('idx_notification_logs_tenant_id', table_name='notification_logs')
op.drop_index('idx_notification_logs_ticket_id', table_name='notification_logs')
op.drop_table('notification_logs')
op.add_column('audit_logs', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
op.alter_column('audit_logs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index('idx_audit_logs_action', table_name='audit_logs')
op.drop_index('idx_audit_logs_correlation_id', table_name='audit_logs')
op.drop_index('idx_audit_logs_created_at', table_name='audit_logs')
op.drop_index('idx_audit_logs_tenant_id', table_name='audit_logs')
op.drop_index('idx_audit_logs_user_id', table_name='audit_logs')
op.create_index('idx_audit_logs_tenant_action', 'audit_logs', ['tenant_id', 'action'], unique=False)
op.create_index('idx_audit_logs_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
op.create_index(op.f('ix_audit_logs_correlation_id'), 'audit_logs', ['correlation_id'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
op.drop_constraint('audit_logs_user_id_fkey', 'audit_logs', type_='foreignkey')
op.drop_constraint('audit_logs_tenant_id_fkey', 'audit_logs', type_='foreignkey')
op.create_foreign_key(None, 'audit_logs', 'users', ['user_id'], ['id'], ondelete='SET NULL')
op.create_foreign_key(None, 'audit_logs', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.drop_table_comment(
'audit_logs',
existing_comment='Bit??cora de acciones para auditor??a y compliance',
schema=None
)
op.drop_index('idx_refresh_tokens_expires', table_name='refresh_tokens')
op.drop_index('idx_refresh_tokens_user_id', table_name='refresh_tokens')
op.create_index('idx_refresh_tokens_user_expires', 'refresh_tokens', ['user_id', 'expires_at'], unique=False)
op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False)
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
op.drop_index('idx_tenants_domain', table_name='tenants')
op.drop_index('idx_tenants_slug', table_name='tenants')
op.drop_index('idx_tenants_status', table_name='tenants')
op.drop_table_comment(
'tenants',
existing_comment='Organizaciones cliente en el sistema multi-tenant',
schema=None
)
op.drop_index('idx_ticket_attachments_comment_id', table_name='ticket_attachments')
op.drop_index('idx_ticket_attachments_ticket_id', table_name='ticket_attachments')
op.drop_index('idx_ticket_attachments_uploaded_by', table_name='ticket_attachments')
op.drop_table_comment(
'ticket_attachments',
existing_comment='Archivos adjuntos en tickets',
schema=None
)
op.drop_index('idx_ticket_comments_author_id', table_name='ticket_comments')
op.drop_index('idx_ticket_comments_created_at', table_name='ticket_comments')
op.drop_index('idx_ticket_comments_ticket_id', table_name='ticket_comments')
op.drop_table_comment(
'ticket_comments',
existing_comment='Comentarios en tickets',
schema=None
)
op.drop_index('idx_tickets_assigned_to', table_name='tickets', postgresql_where='(assigned_to IS NOT NULL)')
op.drop_index('idx_tickets_category', table_name='tickets')
op.drop_index('idx_tickets_created_at', table_name='tickets')
op.drop_index('idx_tickets_created_by', table_name='tickets')
op.drop_index('idx_tickets_number', table_name='tickets')
op.drop_index('idx_tickets_priority', table_name='tickets')
op.drop_index('idx_tickets_sla_resolution', table_name='tickets')
op.drop_index('idx_tickets_sla_response', table_name='tickets')
op.drop_index('idx_tickets_status', table_name='tickets')
op.drop_index('idx_tickets_tenant_id', table_name='tickets')
op.drop_index('idx_tickets_tenant_status', table_name='tickets')
op.drop_table_comment(
'tickets',
existing_comment='Tickets de soporte - core del negocio',
schema=None
)
op.drop_index('idx_users_active', table_name='users')
op.drop_index('idx_users_email', table_name='users')
op.drop_index('idx_users_role', table_name='users')
op.drop_index('idx_users_tenant_email', table_name='users')
op.drop_index('idx_users_tenant_id', table_name='users')
op.drop_table_comment(
'users',
existing_comment='Usuarios del sistema (internos y clientes)',
schema=None
)
op.drop_column('users', 'backup_codes_temp')
op.drop_column('users', 'password_changed_at')
op.drop_column('users', 'totp_secret_temp')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('totp_secret_temp', sa.VARCHAR(length=32), autoincrement=False, nullable=True))
op.add_column('users', sa.Column('password_changed_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True))
op.add_column('users', sa.Column('backup_codes_temp', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True))
op.create_table_comment(
'users',
'Usuarios del sistema (internos y clientes)',
existing_comment=None,
schema=None
)
op.create_index('idx_users_tenant_id', 'users', ['tenant_id'], unique=False)
op.create_index('idx_users_tenant_email', 'users', ['tenant_id', 'email'], unique=False)
op.create_index('idx_users_role', 'users', ['role'], unique=False)
op.create_index('idx_users_email', 'users', ['email'], unique=False)
op.create_index('idx_users_active', 'users', ['is_active'], unique=False)
op.create_table_comment(
'tickets',
'Tickets de soporte - core del negocio',
existing_comment=None,
schema=None
)
op.create_index('idx_tickets_tenant_status', 'tickets', ['tenant_id', 'status'], unique=False)
op.create_index('idx_tickets_tenant_id', 'tickets', ['tenant_id'], unique=False)
op.create_index('idx_tickets_status', 'tickets', ['status'], unique=False)
op.create_index('idx_tickets_sla_response', 'tickets', ['sla_response_due'], unique=False)
op.create_index('idx_tickets_sla_resolution', 'tickets', ['sla_resolution_due'], unique=False)
op.create_index('idx_tickets_priority', 'tickets', ['priority'], unique=False)
op.create_index('idx_tickets_number', 'tickets', ['ticket_number'], unique=False)
op.create_index('idx_tickets_created_by', 'tickets', ['created_by'], unique=False)
op.create_index('idx_tickets_created_at', 'tickets', ['created_at'], unique=False)
op.create_index('idx_tickets_category', 'tickets', ['category_id'], unique=False)
op.create_index('idx_tickets_assigned_to', 'tickets', ['assigned_to'], unique=False, postgresql_where='(assigned_to IS NOT NULL)')
op.create_table_comment(
'ticket_comments',
'Comentarios en tickets',
existing_comment=None,
schema=None
)
op.create_index('idx_ticket_comments_ticket_id', 'ticket_comments', ['ticket_id'], unique=False)
op.create_index('idx_ticket_comments_created_at', 'ticket_comments', ['created_at'], unique=False)
op.create_index('idx_ticket_comments_author_id', 'ticket_comments', ['author_id'], unique=False)
op.create_table_comment(
'ticket_attachments',
'Archivos adjuntos en tickets',
existing_comment=None,
schema=None
)
op.create_index('idx_ticket_attachments_uploaded_by', 'ticket_attachments', ['uploaded_by'], unique=False)
op.create_index('idx_ticket_attachments_ticket_id', 'ticket_attachments', ['ticket_id'], unique=False)
op.create_index('idx_ticket_attachments_comment_id', 'ticket_attachments', ['comment_id'], unique=False)
op.create_table_comment(
'tenants',
'Organizaciones cliente en el sistema multi-tenant',
existing_comment=None,
schema=None
)
op.create_index('idx_tenants_status', 'tenants', ['status'], unique=False)
op.create_index('idx_tenants_slug', 'tenants', ['slug'], unique=False)
op.create_index('idx_tenants_domain', 'tenants', ['domain'], unique=False)
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens')
op.drop_index('idx_refresh_tokens_user_expires', table_name='refresh_tokens')
op.create_index('idx_refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False)
op.create_index('idx_refresh_tokens_expires', 'refresh_tokens', ['expires_at'], unique=False)
op.create_table_comment(
'audit_logs',
'Bit??cora de acciones para auditor??a y compliance',
existing_comment=None,
schema=None
)
op.drop_constraint(None, 'audit_logs', type_='foreignkey')
op.drop_constraint(None, 'audit_logs', type_='foreignkey')
op.create_foreign_key('audit_logs_tenant_id_fkey', 'audit_logs', 'tenants', ['tenant_id'], ['id'])
op.create_foreign_key('audit_logs_user_id_fkey', 'audit_logs', 'users', ['user_id'], ['id'])
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_correlation_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
op.drop_index('idx_audit_logs_user_created', table_name='audit_logs')
op.drop_index('idx_audit_logs_tenant_action', table_name='audit_logs')
op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'], unique=False)
op.create_index('idx_audit_logs_tenant_id', 'audit_logs', ['tenant_id'], unique=False)
op.create_index('idx_audit_logs_created_at', 'audit_logs', ['created_at'], unique=False)
op.create_index('idx_audit_logs_correlation_id', 'audit_logs', ['correlation_id'], unique=False)
op.create_index('idx_audit_logs_action', 'audit_logs', ['action'], unique=False)
op.alter_column('audit_logs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.drop_column('audit_logs', 'updated_at')
op.create_table('notification_logs',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('recipient_email', sa.VARCHAR(length=320), autoincrement=False, nullable=False),
sa.Column('subject', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
sa.Column('template_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('provider', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('external_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('sent_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.CheckConstraint("status::text = ANY (ARRAY['pending'::character varying, 'sent'::character varying, 'failed'::character varying, 'bounced'::character varying]::text[])", name='notification_logs_status_check'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='notification_logs_tenant_id_fkey'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='notification_logs_ticket_id_fkey'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='notification_logs_user_id_fkey'),
sa.PrimaryKeyConstraint('id', name='notification_logs_pkey')
)
op.create_index('idx_notification_logs_ticket_id', 'notification_logs', ['ticket_id'], unique=False)
op.create_index('idx_notification_logs_tenant_id', 'notification_logs', ['tenant_id'], unique=False)
op.create_index('idx_notification_logs_status', 'notification_logs', ['status'], unique=False)
op.create_index('idx_notification_logs_recipient', 'notification_logs', ['recipient_email'], unique=False)
op.create_index('idx_notification_logs_created_at', 'notification_logs', ['created_at'], unique=False)
op.create_table('ticket_status_history',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('changed_by', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('old_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=True),
sa.Column('new_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=False),
sa.Column('old_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('new_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('comment', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], name='ticket_status_history_changed_by_fkey'),
sa.ForeignKeyConstraint(['new_assigned_to'], ['users.id'], name='ticket_status_history_new_assigned_to_fkey'),
sa.ForeignKeyConstraint(['old_assigned_to'], ['users.id'], name='ticket_status_history_old_assigned_to_fkey'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_status_history_ticket_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='ticket_status_history_pkey')
)
op.create_index('idx_ticket_status_history_ticket_id', 'ticket_status_history', ['ticket_id'], unique=False)
op.create_index('idx_ticket_status_history_created_at', 'ticket_status_history', ['created_at'], unique=False)
op.create_index('idx_ticket_status_history_changed_by', 'ticket_status_history', ['changed_by'], unique=False)
op.create_table('email_templates',
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('template_key', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('subject_template', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('html_template', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('text_template', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('is_system', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('required_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('default_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('from_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('from_email', sa.VARCHAR(length=320), autoincrement=False, nullable=True),
sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('last_used_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('usage_count', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='email_templates_tenant_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='email_templates_pkey')
)
op.create_index('idx_email_templates_tenant_id', 'email_templates', ['tenant_id'], unique=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { tickets } from '$lib/stores/tickets';
import { toast } from '$lib/stores/toast';
export let ticketId: string;
const dispatch = createEventDispatcher();
const PRIORITIES = [
{ value: 'LOW', label: 'Baja' },
{ value: 'MEDIUM', label: 'Media' },
{ value: 'HIGH', label: 'Alta' },
{ value: 'URGENT', label: 'Urgente' }
];
let content = '';
let priority = 'MEDIUM';
let file: File | null = null;
let isSubmitting = false;
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
file = input.files?.[0] ?? null;
}
async function handleSubmit() {
if (!content.trim()) {
toast.error('El contenido del asunto es obligatorio');
return;
}
isSubmitting = true;
try {
await tickets.createIssue(ticketId, {
content: content.trim(),
priority,
tagged_user_ids: [],
file
});
toast.success('Asunto creado correctamente');
dispatch('created');
dispatch('close');
} catch (e: any) {
toast.error(e.message || 'Error al crear el asunto');
} finally {
isSubmitting = 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">
<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">Crear 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>
<div class="px-6 py-5 space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Descripción <span class="text-red-500">*</span>
</label>
<textarea
rows="4"
bind:value={content}
disabled={isSubmitting}
placeholder="Describe el asunto de escalación..."
class="form-input w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
<select bind:value={priority} disabled={isSubmitting} class="form-input w-full">
{#each PRIORITIES as p}
<option value={p.value}>{p.label}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Adjunto (opcional)
</label>
<input
type="file"
on:change={handleFileChange}
disabled={isSubmitting}
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4 file:rounded-md
file:border-0 file:text-sm file:font-medium
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100 disabled:opacity-50"
/>
{#if file}
<p class="text-xs text-gray-500 mt-1">{file.name}</p>
{/if}
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button
type="button"
on:click={handleClose}
disabled={isSubmitting}
class="btn-secondary px-4 py-2"
>
Cancelar
</button>
<button
type="button"
on:click={handleSubmit}
disabled={isSubmitting || !content.trim()}
class="btn-primary px-4 py-2 disabled:opacity-50"
>
{#if isSubmitting}
<div class="flex items-center gap-2">
<div class="spinner w-4 h-4" />
<span>Creando...</span>
</div>
{:else}
Crear Asunto
{/if}
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export interface User {
id: string;
email: string;
@@ -11,50 +12,68 @@ export interface User {
is_two_factor_enabled: boolean;
created_at: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginRequest {
email: string;
password: string;
tenant_slug: string;
totp_code?: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user: User;
}
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false
};
function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
// Verifica sesión activa al cargar la app (cookie HttpOnly)
init: async () => {
if (typeof window !== 'undefined') {
if (typeof window === 'undefined') return;
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
headers: {
'X-App': 'client',
// Sin X-Tenant-ID aquí — /auth/me lee el tenant del JWT directamente
}
});
if (response.ok) {
const user = await response.json();
// Token es null — la autenticación viaja por cookie HttpOnly
// El store solo necesita el user para la UI
set({ user, token: null, isAuthenticated: true, isLoading: false });
} else {
// Cookie expirada o inválida — limpiar estado
set(initialState);
}
} catch (error) {}
} catch {
set(initialState);
}
},
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
@@ -72,12 +91,15 @@ function createAuthStore() {
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
// Guardamos el token en memoria para requests inmediatos
// Si la página se recarga, init() recupera la sesión desde la cookie
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
logout: async () => {
try {
const token = _state.token;
@@ -86,7 +108,6 @@ function createAuthStore() {
credentials: 'include',
headers: {
'X-App': 'client',
'X-Tenant-Slug': 'aduanasoft',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
@@ -96,9 +117,11 @@ function createAuthStore() {
window.location.href = '/login';
}
},
updateUser: (user: User) => { update(state => ({ ...state, user })); },
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
};
}
export const auth = createAuthStore();

View File

@@ -2,7 +2,6 @@ import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { auth } from './auth';
// Types
interface FastAPIValidationError {
loc: (string | number)[];
msg: string;
@@ -24,6 +23,11 @@ export interface Ticket {
updated_at: string;
due_date: string | null;
resolution: string | null;
first_response_at?: string | null;
resolved_at?: string | null;
sla_response_due?: string | null;
sla_resolution_due?: string | null;
created_by?: string;
}
export interface TicketComment {
@@ -50,6 +54,19 @@ export interface TicketAttachment {
uploaded_at: string;
}
export interface TicketIssue {
id: string;
ticket_id: string;
content: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
created_by: string;
created_by_name: string;
tagged_users: { id: string; full_name: string; email: string }[];
attachment_filename: string | null;
created_at: string;
updated_at: string;
}
export interface CreateTicketRequest {
title: string;
description: string;
@@ -62,33 +79,35 @@ export interface TicketsState {
currentTicket: Ticket | null;
comments: TicketComment[];
attachments: TicketAttachment[];
issues: TicketIssue[];
isLoading: boolean;
error: string | null;
}
// Initial state
const initialState: TicketsState = {
tickets: [],
currentTicket: null,
comments: [],
attachments: [],
issues: [],
isLoading: false,
error: null
};
// API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
if (!authState.user) {
throw new Error('Not authenticated');
}
// Verificar que el usuario sigue autenticado
if (!authState.isAuthenticated) throw new Error('Session expired');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
...(options.headers as Record<string, string>)
};
// Solo agregar Authorization si el token existe en memoria
// Si no está (recarga de página), la cookie HttpOnly lo maneja
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
@@ -102,13 +121,11 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
let errorMessage = 'Request failed';
try {
const error = await response.json();
console.error('❌ API Error Response:', error);
// Manejar diferentes formatos de error de FastAPI
if (error.detail) {
if (Array.isArray(error.detail)) {
// Errores de validación de FastAPI
errorMessage = error.detail.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`).join(', ');
errorMessage = error.detail
.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`)
.join(', ');
} else if (typeof error.detail === 'string') {
errorMessage = error.detail;
} else {
@@ -117,34 +134,29 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
} else {
errorMessage = JSON.stringify(error);
}
} catch (e) {
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
}
// Create tickets store
function createTicketsStore() {
const { subscribe, set, update }: Writable<TicketsState> = writable(initialState);
const { subscribe, update }: Writable<TicketsState> = writable(initialState);
return {
subscribe,
// Load user's tickets
loadTickets: async () => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
update(state => ({ ...state, isLoading: true, error: null }));
try {
const raw = await apiCall('/tickets/');
// El backend devuelve 'subject', el tipo Ticket usa 'title'
const tickets = raw.map((t: any) => ({ ...t, title: t.subject ?? t.title }));
update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
update(state => ({ ...state, tickets, isLoading: false }));
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tickets'
@@ -152,78 +164,117 @@ function createTicketsStore() {
}
},
// Load specific ticket with details
loadTicket: async (ticketId: string) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
update(state => ({ ...state, isLoading: true, error: null }));
try {
const [ticketRaw, comments, attachments] = await Promise.all([
const [ticketRaw, comments, attachments, issues] = await Promise.all([
apiCall(`/tickets/${ticketId}`),
apiCall(`/tickets/${ticketId}/comments`),
apiCall(`/tickets/${ticketId}/attachments`)
apiCall(`/tickets/${ticketId}/attachments`),
apiCall(`/tickets/${ticketId}/issues`)
]);
// El backend devuelve 'subject', el tipo Ticket usa 'title'
const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title };
update((state: TicketsState) => ({
update(state => ({
...state,
currentTicket: ticket,
comments,
attachments,
issues,
isLoading: false
}));
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load ticket'
}));
}
},
// Reload only comments silently (for polling)
reloadComments: async (ticketId: string) => {
try {
const comments = await apiCall(`/tickets/${ticketId}/comments`);
update((state: TicketsState) => ({ ...state, comments }));
} catch (error) {
// Silent fail - no mostrar error en polling
console.error('Error reloading comments:', error);
update(state => ({ ...state, comments }));
} catch {
// Silent fail en polling
}
},
createIssue: async (ticketId: string, data: {
content: string;
priority: string;
tagged_user_ids: string[];
file?: File | null;
}) => {
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
if (!authState.isAuthenticated) throw new Error('Session expired');
const headers: Record<string, string> = { 'X-App': 'client' };
// Solo agregar token si existe — sino la cookie HttpOnly lo maneja
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
let body: FormData | string;
if (data.file) {
const formData = new FormData();
formData.append('content', data.content);
formData.append('priority', data.priority);
data.tagged_user_ids.forEach(id => formData.append('tagged_user_ids', id));
formData.append('file', data.file);
body = formData;
// NO poner Content-Type — el browser lo agrega con boundary automáticamente
} else {
headers['Content-Type'] = 'application/json';
body = JSON.stringify({
content: data.content,
priority: data.priority,
tagged_user_ids: data.tagged_user_ids
});
}
const response = await fetch(`/api/v1/tickets/${ticketId}/issues`, {
method: 'POST',
credentials: 'include',
headers,
body
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Error creating issue' }));
throw new Error(
typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail)
);
}
const newIssue = await response.json();
update(state => ({ ...state, issues: [newIssue, ...state.issues] }));
return newIssue;
},
// Create new ticket
createTicket: async (ticket: CreateTicketRequest) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
update(state => ({ ...state, isLoading: true, error: null }));
try {
// Mapear campos del frontend al formato del backend
const ticketData = {
subject: ticket.title, // ← Backend espera "subject" no "title"
subject: ticket.title,
description: ticket.description,
category_id: ticket.category_id,
priority: ticket.priority,
system_id: null // ← Opcional
system_id: null
};
console.log('Sending ticket data:', ticketData);
const newTicket = await apiCall('/tickets/', {
method: 'POST',
body: JSON.stringify(ticketData)
});
update((state: TicketsState) => ({
update(state => ({
...state,
tickets: [newTicket, ...state.tickets],
isLoading: false
}));
return newTicket;
} catch (error) {
console.error('Create ticket error:', error);
update((state: TicketsState) => ({
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to create ticket'
@@ -232,22 +283,18 @@ function createTicketsStore() {
}
},
// Add comment to ticket
addComment: async (ticketId: string, content: string) => {
try {
const comment = await apiCall(`/tickets/${ticketId}/comments`, {
method: 'POST',
body: JSON.stringify({ content })
// is_internal siempre false desde el frontend cliente
// el backend además lo bloquearía si fuera true (fix de seguridad pendiente)
body: JSON.stringify({ content, is_internal: false })
});
update((state: TicketsState) => ({
...state,
comments: [...state.comments, comment]
}));
update(state => ({ ...state, comments: [...state.comments, comment] }));
return comment;
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to add comment'
}));
@@ -255,17 +302,12 @@ function createTicketsStore() {
}
},
// Upload attachment
uploadAttachment: async (ticketId: string, file: File) => {
try {
const formData = new FormData();
formData.append('file', file);
const authState = get(auth);
if (!authState.user) {
throw new Error('Not authenticated');
}
if (!authState.user) throw new Error('Not authenticated');
const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
@@ -285,15 +327,10 @@ function createTicketsStore() {
const result = await response.json();
const attachment = result.data || result;
update((state: TicketsState) => ({
...state,
attachments: [...state.attachments, attachment]
}));
update(state => ({ ...state, attachments: [...state.attachments, attachment] }));
return attachment;
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to upload attachment'
}));
@@ -301,23 +338,20 @@ function createTicketsStore() {
}
},
// Close ticket (client can close their own tickets)
closeTicket: async (ticketId: string, resolution?: string) => {
try {
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
method: 'PATCH',
body: JSON.stringify({ resolution })
body: JSON.stringify({ resolution_notes: resolution })
});
update((state: TicketsState) => ({
update(state => ({
...state,
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
tickets: state.tickets.map((t: Ticket) => t.id === ticketId ? updatedTicket : t)
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
}));
return updatedTicket;
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to close ticket'
}));
@@ -325,45 +359,36 @@ function createTicketsStore() {
}
},
// Clear error
clearError: () => {
update((state: TicketsState) => ({ ...state, error: null }));
},
clearError: () => { update(state => ({ ...state, error: null })); },
// Clear current ticket
clearCurrentTicket: () => {
update((state: TicketsState) => ({
update(state => ({
...state,
currentTicket: null,
comments: [],
attachments: []
attachments: [],
issues: []
}));
},
// Download attachment
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
const authState = get(auth);
if (!authState.user) {
throw new Error('Not authenticated');
}
if (!authState.user) throw new Error('Not authenticated');
const dlHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET',
credentials: 'include',
headers: dlHeaders
});
const response = await fetch(
`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`,
{ method: 'GET', credentials: 'include', headers: dlHeaders }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Download failed' }));
throw new Error(error.detail || 'Download failed');
}
// Crear blob y descargar
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');

View File

@@ -5,6 +5,7 @@
import { tickets } from '$lib/stores/tickets.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
import IssueModal from '$lib/components/IssueModal.svelte';
let ticketId: string;
let newComment = '';
@@ -14,55 +15,38 @@
let closeResolution = '';
let fileInput: HTMLInputElement;
let isUploading = false;
let showAttachments = true;
let showIssueModal = false;
let pollingInterval: any = null;
let showAttachments = true; // Variable para controlar la visibilidad de attachments
async function loadComments() {
try {
await tickets.reloadComments(ticketId);
} catch (error) {
// No mostrar error en polling silencioso
console.error('Error recargando comentarios:', error);
}
}
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
ticketId = $page.params.id;
if (ticketId) {
tickets.loadTicket(ticketId);
// Polling cada 3 segundos
pollingInterval = setInterval(() => {
loadComments();
}, 3000);
pollingInterval = setInterval(() => { loadComments(); }, 3000);
}
// Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
};
return () => { if (pollingInterval) clearInterval(pollingInterval); };
});
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
// Status mapping
const statusConfig: Record<string, { label: string; class: string }> = {
NEW: { label: 'Nuevo', class: 'badge-new' },
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
@@ -74,7 +58,6 @@
};
const fallbackStatus = { label: 'Desconocido', class: 'badge-new' };
// Priority mapping
const priorityConfig: Record<string, { label: string; class: string }> = {
LOW: { label: 'Baja', class: 'badge-priority-low' },
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
@@ -85,7 +68,6 @@
async function handleAddComment() {
if (!newComment.trim()) return;
isSubmittingComment = true;
try {
await tickets.addComment(ticketId, newComment.trim());
@@ -102,34 +84,23 @@
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error('El archivo es demasiado grande. Máximo 10MB');
target.value = '';
return;
}
// Validate file type
const allowedTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'application/msword',
'image/jpeg','image/png','image/gif','image/webp','application/pdf',
'text/plain','application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];
if (!allowedTypes.includes(file.type)) {
toast.error('Tipo de archivo no permitido');
target.value = '';
return;
}
isUploading = true;
try {
await tickets.uploadAttachment(ticketId, file);
@@ -147,14 +118,11 @@
await tickets.downloadAttachment(ticketId, attachment.id, attachment.original_filename);
toast.success('Descarga iniciada');
} catch (error: any) {
console.error('Download error:', error);
toast.error(error.message || 'Error al descargar el archivo');
}
}
function handleCloseTicket() {
showCloseDialog = true;
}
function handleCloseTicket() { showCloseDialog = true; }
async function confirmCloseTicket() {
isClosingTicket = true;
@@ -175,7 +143,6 @@
closeResolution = '';
}
// Check if user can close ticket
$: canClose =
$tickets.currentTicket &&
['RESOLVED', 'WAITING_CUSTOMER'].includes($tickets.currentTicket.status);
@@ -195,16 +162,6 @@
</div>
{:else if $tickets.error}
<div class="text-center py-12">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar ticket</h3>
<p class="text-gray-600 mb-4">{$tickets.error}</p>
<button on:click={() => tickets.loadTicket(ticketId)} class="btn-primary px-4 py-2">
@@ -244,32 +201,19 @@
</span>
</div>
</div>
{#if canClose}
<button
on:click={handleCloseTicket}
class="btn-success px-4 py-2"
disabled={isClosingTicket}
>
<button on:click={handleCloseTicket} class="btn-success px-4 py-2" disabled={isClosingTicket}>
Cerrar Ticket
</button>
{/if}
</div>
</div>
<div class="card-content">
<div class="prose max-w-none">
<p class="whitespace-pre-wrap text-gray-700">
{$tickets.currentTicket.description}
</p>
</div>
<p class="whitespace-pre-wrap text-gray-700">{$tickets.currentTicket.description}</p>
{#if $tickets.currentTicket.resolution}
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 class="font-medium text-green-900 mb-2">Resolución:</h4>
<p class="text-green-800 whitespace-pre-wrap">
{$tickets.currentTicket.resolution}
</p>
<p class="text-green-800 whitespace-pre-wrap">{$tickets.currentTicket.resolution}</p>
</div>
{/if}
</div>
@@ -282,75 +226,35 @@
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<span>Archivos Adjuntos</span>
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{$tickets.attachments.length} archivo{$tickets.attachments.length !== 1
? 's'
: ''}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{$tickets.attachments.length}
</span>
</h3>
<button
class="text-sm text-gray-500 hover:text-gray-700"
title="Ver/Ocultar archivos adjuntos"
on:click={() => (showAttachments = !showAttachments)}
>
<button class="text-sm text-gray-500 hover:text-gray-700"
on:click={() => (showAttachments = !showAttachments)}>
{showAttachments ? 'Ocultar' : 'Ver'} archivos
</button>
</div>
</div>
{#if showAttachments}
<div class="card-content">
<div class="space-y-3">
<div class="card-content space-y-3">
{#each $tickets.attachments as attachment}
<div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
<svg
class="w-4 h-4 text-gray-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>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">
{attachment.original_filename}
</p>
<p class="text-sm font-medium text-gray-900">{attachment.original_filename}</p>
<p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB • Subido por {attachment.uploaded_by_name}
{formatDate(attachment.uploaded_at)}
{Math.round(attachment.size_bytes / 1024)} KB • {attachment.uploaded_by_name}
</p>
</div>
</div>
<button
on:click={() => handleDownloadAttachment(attachment)}
class="btn-ghost p-2 hover:bg-blue-100 rounded-md transition-colors"
title="Descargar archivo"
>
<button on:click={() => handleDownloadAttachment(attachment)} class="btn-ghost p-2">
<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="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"
/>
<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>
</button>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
@@ -362,104 +266,67 @@
</div>
<div class="card-content">
{#if $tickets.comments.length === 0}
<p class="text-gray-500 text-center py-4">
No hay comentarios aún. ¡Sé el primero en comentar!
</p>
<p class="text-gray-500 text-center py-4">No hay comentarios aún.</p>
{:else}
<div class="space-y-4">
{#each $tickets.comments as comment}
{console.log('Comment:', comment)}
<div class="flex space-x-3">
<div
class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-primary-600 text-xs font-medium">
{comment.author_name
? comment.author_name
.split(' ')
.map(n => n[0])
.join('')
: '??'}
{comment.author_name?.split(' ').map(n => n[0]).join('') ?? '??'}
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="text-sm font-medium text-gray-900">
{comment.author_name}
</span>
<span class="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
<span class="text-sm font-medium text-gray-900">{comment.author_name}</span>
<span class="text-xs text-gray-500">{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">
Interno
</span>
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">Interno</span>
{/if}
</div>
<p class="text-gray-700 whitespace-pre-wrap">
{comment.content}
</p>
<p class="text-gray-700 whitespace-pre-wrap">{comment.content}</p>
</div>
</div>
{/each}
</div>
{/if}
<!-- Add Comment Form -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="space-y-4">
<div class="mt-6 pt-6 border-t border-gray-200 space-y-4">
<textarea
rows="4"
class="form-input"
placeholder="Escribe tu comentario o respuesta..."
placeholder="Escribe tu comentario..."
bind:value={newComment}
disabled={isSubmittingComment}
/>
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<input
type="file"
bind:this={fileInput}
on:change={handleFileUpload}
<div>
<input type="file" bind:this={fileInput} on:change={handleFileUpload}
class="hidden"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx"
disabled={isUploading}
/>
<button
type="button"
on:click={() => fileInput.click()}
class="btn-ghost p-2 flex items-center space-x-2"
disabled={isUploading}
>
disabled={isUploading} />
<button type="button" on:click={() => fileInput.click()}
class="btn-ghost p-2 flex items-center space-x-2" disabled={isUploading}>
{#if isUploading}
<div class="spinner w-4 h-4" />
{:else}
<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.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"
/>
<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>
{/if}
<span class="text-sm">Adjuntar archivo</span>
<span class="text-sm">Adjuntar</span>
</button>
</div>
<button
on:click={handleAddComment}
class="btn-primary px-4 py-2"
disabled={isSubmittingComment || !newComment.trim()}
>
<button on:click={handleAddComment} class="btn-primary px-4 py-2"
disabled={isSubmittingComment || !newComment.trim()}>
{#if isSubmittingComment}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" />
<span>Enviando...</span>
</div>
{:else}
Enviar Comentario
Enviar
{/if}
</button>
</div>
@@ -467,7 +334,6 @@
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
@@ -479,40 +345,30 @@
<div class="card-content space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
<dd class="text-sm text-gray-900 font-mono">
#{$tickets.currentTicket.id.substring(0, 8)}
</dd>
<dd class="text-sm text-gray-900 font-mono">#{$tickets.currentTicket.id.substring(0, 8)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Categoría</dt>
<dd class="text-sm text-gray-900">
{$tickets.currentTicket.category_name || 'Sin categoría'}
</dd>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.category_name || 'Sin categoría'}</dd>
</div>
{#if $tickets.currentTicket.assigned_to_name}
<div>
<dt class="text-sm font-medium text-gray-500">Asignado a</dt>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.assigned_to_name}</dd>
</div>
{/if}
<div>
<dt class="text-sm font-medium text-gray-500">Creado</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.created_at)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Última actualización</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.updated_at)}</dd>
</div>
<!-- SLA -->
{#if $tickets.currentTicket.sla_response_due || $tickets.currentTicket.sla_resolution_due}
<div class="pt-3 border-t border-gray-100">
<dt class="text-sm font-medium text-gray-500 mb-2">Tiempos de SLA</dt>
{#if $tickets.currentTicket.sla_response_due}
{@const respDue = new Date($tickets.currentTicket.sla_response_due)}
{@const respVencido = respDue < new Date() && !$tickets.currentTicket.first_response_at}
@@ -528,7 +384,6 @@
</dd>
</div>
{/if}
{#if $tickets.currentTicket.sla_resolution_due}
{@const resDue = new Date($tickets.currentTicket.sla_resolution_due)}
{@const resVencido = resDue < new Date() && !$tickets.currentTicket.resolved_at}
@@ -546,117 +401,79 @@
{/if}
</div>
{/if}
</div>
</div>
{#if $tickets.currentTicket.due_date}
<div>
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt>
<dd
class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) <
new Date()
? 'text-red-600'
: ''}"
>
{formatDate($tickets.currentTicket.due_date)}
{#if new Date($tickets.currentTicket.due_date) < new Date()}
<span class="block text-xs text-red-500">¡Vencido!</span>
<!-- Asuntos -->
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">
Asuntos
{#if $tickets.issues.length > 0}
<span class="text-gray-400 font-normal text-sm ml-1">({$tickets.issues.length})</span>
{/if}
</dd>
</h3>
<button type="button" on:click={() => showIssueModal = true}
class="text-sm text-blue-600 hover:text-blue-700 font-medium">
+ Crear
</button>
</div>
{#if $tickets.issues.length > 0}
<div class="divide-y divide-gray-100">
{#each $tickets.issues as issue}
<div class="card-content py-3">
<p class="text-sm text-gray-700">{issue.content}</p>
<span class="text-xs text-gray-400 mt-1 block">{issue.priority} · {issue.created_by_name}</span>
</div>
{/each}
</div>
{:else}
<div class="card-content">
<p class="text-sm text-gray-500">No hay asuntos creados.</p>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Issue Modal -->
{#if showIssueModal && $tickets.currentTicket}
<IssueModal
ticketId={$tickets.currentTicket.id}
on:close={() => showIssueModal = false}
on:created={() => showIssueModal = false}
/>
{/if}
<!-- Close Ticket Dialog -->
{#if showCloseDialog}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div
class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<div
class="fixed inset-0 transition-opacity"
role="dialog"
tabindex="0"
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20">
<div class="fixed inset-0 bg-gray-500 opacity-75"
role="dialog" tabindex="0"
on:click={cancelCloseTicket}
on:keydown={e => e.key === 'Escape' && cancelCloseTicket()}
>
<div class="absolute inset-0 bg-gray-500 opacity-75" />
</div>
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900">Cerrar Ticket</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el
problema ha sido resuelto satisfactoriamente.
on:keydown={e => e.key === 'Escape' && cancelCloseTicket()} />
<div class="relative bg-white rounded-lg shadow-xl sm:max-w-lg w-full z-10 p-6">
<h3 class="text-lg font-medium text-gray-900 mb-2">Cerrar Ticket</h3>
<p class="text-sm text-gray-500 mb-4">
¿Estás seguro de que quieres cerrar este ticket?
</p>
</div>
<div class="mt-4">
<label for="close-resolution" class="form-label">
Comentario de cierre (opcional)
</label>
<textarea
id="close-resolution"
rows="3"
class="form-input"
placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..."
bind:value={closeResolution}
disabled={isClosingTicket}
/>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
class="w-full inline-flex justify-center btn-success px-4 py-2 sm:ml-3 sm:w-auto disabled:opacity-50"
disabled={isClosingTicket}
on:click={confirmCloseTicket}
>
<textarea rows="3" class="form-input w-full mb-4"
placeholder="Comentario de cierre (opcional)..."
bind:value={closeResolution} disabled={isClosingTicket} />
<div class="flex justify-end gap-3">
<button class="btn-secondary px-4 py-2" disabled={isClosingTicket} on:click={cancelCloseTicket}>
Cancelar
</button>
<button class="btn-success px-4 py-2 disabled:opacity-50" disabled={isClosingTicket} on:click={confirmCloseTicket}>
{#if isClosingTicket}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" />
<span>Cerrando...</span>
</div>
<div class="flex items-center gap-2"><div class="spinner w-4 h-4" /><span>Cerrando...</span></div>
{:else}
Cerrar Ticket
{/if}
</button>
<button
type="button"
class="mt-3 w-full inline-flex justify-center btn-secondary px-4 py-2 sm:mt-0 sm:w-auto"
disabled={isClosingTicket}
on:click={cancelCloseTicket}
>
Cancelar
</button>
</div>
</div>
</div>

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import IssueModal from '$lib/components/IssueModal.svelte';
import ParticipantsModal from '$lib/components/ParticipantsModal.svelte';
import { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api';
import { onMount } from 'svelte';
import ParticipantsModal from '$lib/components/ParticipantsModal.svelte';
import IssueModal from '$lib/components/IssueModal.svelte';
let showParticipants = false;
let showIssueModal = false;
@@ -49,11 +49,12 @@
async function loadData() {
isLoading = true;
try {
const [ticketData, commentsData, attachmentsData, usersData] = await Promise.all([
const [ticketData, commentsData, attachmentsData, usersData, issuesData] = await Promise.all([
api.get(`/tickets/${ticketId}`),
api.get(`/tickets/${ticketId}/comments`),
api.get(`/tickets/${ticketId}/attachments`),
api.get('/users/')
api.get('/users/'),
api.get(`/tickets/${ticketId}/issues`)
]);
ticket = ticketData;
comments = commentsData;
@@ -294,7 +295,9 @@
{formatDate(comment.created_at)}
</span>
{#if comment.is_internal}
<span class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200">
<span
class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200"
>
Interno
</span>
{/if}
@@ -384,11 +387,13 @@
<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">
Asuntos {#if issues.length > 0}<span class="text-gray-400 font-normal">({issues.length})</span>{/if}
Asuntos {#if issues.length > 0}<span class="text-gray-400 font-normal"
>({issues.length})</span
>{/if}
</h3>
<button
type="button"
on:click={() => showIssueModal = true}
on:click={() => (showIssueModal = true)}
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Crear asunto
@@ -400,7 +405,9 @@
<div class="px-6 py-4">
<div class="flex items-start justify-between gap-2">
<p class="text-sm text-gray-700 flex-1">{issue.content}</p>
<span class="text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 shrink-0">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 shrink-0"
>
{issue.priority}
</span>
</div>
@@ -425,7 +432,9 @@
<!-- 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">
@@ -433,15 +442,21 @@
<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-gray-50 text-red-700 border border-red-200">
<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-gray-50 text-green-700 border border-green-200">
<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-gray-50 text-blue-700 border border-blue-200">
<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}
@@ -455,15 +470,21 @@
<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-gray-50 text-red-700 border border-red-200">
<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-gray-50 text-green-700 border border-green-200">
<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-gray-50 text-blue-700 border border-blue-200">
<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}
@@ -481,15 +502,18 @@
<ParticipantsModal
ticketId={ticket.id}
createdBy={ticket.created_by}
on:close={() => showParticipants = false}
on:close={() => (showParticipants = false)}
/>
{/if}
{#if showIssueModal && ticket}
<IssueModal
ticketId={ticket.id}
{users}
on:close={() => showIssueModal = false}
on:created={() => { showIssueModal = false; loadData(); }}
on:close={() => (showIssueModal = false)}
on:created={() => {
showIssueModal = false;
loadData();
}}
/>
{/if}
</div>