Permisos en progreso
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
67
backend/app/models/permission.py
Normal file
67
backend/app/models/permission.py
Normal 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 '✗'})>"
|
||||
@@ -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 ###
|
||||
146
frontend-client/src/lib/components/IssueModal.svelte
Normal file
146
frontend-client/src/lib/components/IssueModal.svelte
Normal 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>
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -65,7 +66,7 @@
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddComment() {
|
||||
if (!newComment.trim()) return;
|
||||
@@ -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>
|
||||
@@ -420,12 +427,14 @@
|
||||
{:else}
|
||||
<p class="px-6 py-4 text-sm text-gray-500">No hay asuntos creados.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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}
|
||||
{/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}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user