Compare commits

..

3 Commits

Author SHA1 Message Date
94ca24e00e Permiso vistas implementados 2026-03-24 13:57:54 -06:00
7b00f1336d Asunto funcionando 2026-03-24 08:27:13 -06:00
08816ab73a Asunto en internos funcionando 2026-03-24 08:01:57 -06:00
25 changed files with 2111 additions and 707 deletions

View File

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

View File

@@ -0,0 +1,130 @@
"""Permissions endpoints - ServiceManagerWeb"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from typing import Dict
import uuid
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User, UserRole
from app.models.permission import TenantPermission, DEFAULT_PERMISSIONS, CLIENT_PERMISSIONS
router = APIRouter()
@router.get("/my")
async def get_my_permissions(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Dict[str, bool]:
"""Obtener permisos efectivos del usuario actual."""
if current_user.role.is_global:
# Staff interno — devolver permisos del rol por defecto
role_defaults = DEFAULT_PERMISSIONS.get(current_user.role.value, {})
return role_defaults
# Buscar overrides individuales primero
result = await db.execute(
select(TenantPermission).where(
TenantPermission.tenant_id == current_user.tenant_id,
TenantPermission.user_id == current_user.id,
)
)
user_overrides = {p.permission: p.granted for p in result.scalars().all()}
# Buscar defaults del rol
result = await db.execute(
select(TenantPermission).where(
TenantPermission.tenant_id == current_user.tenant_id,
TenantPermission.user_id == None,
TenantPermission.role == current_user.role.value,
)
)
role_defaults = {p.permission: p.granted for p in result.scalars().all()}
# Merge: override individual > default del rol > system default
system_defaults = DEFAULT_PERMISSIONS.get(current_user.role.value, {})
effective = {**system_defaults, **role_defaults, **user_overrides}
return effective
@router.get("/user/{user_id}")
async def get_user_permissions(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Dict[str, bool]:
"""Obtener permisos efectivos de un usuario específico."""
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.CLIENT_ADMIN):
raise HTTPException(status_code=403, detail="Sin permisos")
target = await db.get(User, user_id)
if not target:
raise HTTPException(status_code=404, detail="Usuario no encontrado")
# Buscar overrides individuales
result = await db.execute(
select(TenantPermission).where(
TenantPermission.user_id == user_id,
)
)
user_overrides = {p.permission: p.granted for p in result.scalars().all()}
# Buscar defaults del rol en el tenant
result = await db.execute(
select(TenantPermission).where(
TenantPermission.tenant_id == target.tenant_id,
TenantPermission.user_id == None,
TenantPermission.role == target.role.value,
)
)
role_defaults = {p.permission: p.granted for p in result.scalars().all()}
system_defaults = DEFAULT_PERMISSIONS.get(target.role.value, {})
effective = {**system_defaults, **role_defaults, **user_overrides}
return effective
@router.put("/user/{user_id}")
async def update_user_permissions(
user_id: uuid.UUID,
data: Dict[str, bool],
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Actualizar permisos individuales de un usuario.
Solo ADMIN, SUPPORT_MANAGER o CLIENT_ADMIN pueden hacerlo.
"""
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.CLIENT_ADMIN):
raise HTTPException(status_code=403, detail="Sin permisos")
target = await db.get(User, user_id)
if not target:
raise HTTPException(status_code=404, detail="Usuario no encontrado")
# Borrar overrides existentes del usuario
await db.execute(
delete(TenantPermission).where(
TenantPermission.user_id == user_id,
)
)
# Insertar nuevos overrides
from datetime import datetime
now = datetime.utcnow()
for permission, granted in data.items():
db.add(TenantPermission(
id=uuid.uuid4(),
tenant_id=target.tenant_id,
user_id=user_id,
role=target.role.value,
permission=permission,
granted=granted,
created_at=now,
updated_at=now,
))
await db.commit()
return {"message": "Permisos actualizados"}

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants, permissions
api_router = APIRouter() api_router = APIRouter()
@@ -86,4 +86,11 @@ api_router.include_router(
reports.router, reports.router,
prefix="/reports", prefix="/reports",
tags=["reports"] tags=["reports"]
)
# Permissions routes
api_router.include_router(
permissions.router,
prefix="/permissions",
tags=["permissions"]
) )

View File

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

View File

@@ -14,24 +14,99 @@ from app.core.database import Base, GUID
CLIENT_PERMISSIONS = [ CLIENT_PERMISSIONS = [
# Permisos funcionales
"view_tickets", "view_tickets",
"create_tickets", "create_tickets",
"close_tickets", "close_tickets",
"view_reports", "view_reports",
"manage_tenant_users", "manage_tenant_users",
"create_issues", "create_issues",
# Permisos de navegación - cliente
"nav:tickets",
"nav:organization",
"nav:usuarios",
]
INTERNAL_PERMISSIONS = [
# Permisos de navegación - interno
"nav:tickets",
"nav:users",
"nav:tenants",
"nav:categories",
"nav:systems",
"nav:sla",
"nav:audit",
"nav:reports",
] ]
DEFAULT_PERMISSIONS = { DEFAULT_PERMISSIONS = {
"CLIENT_ADMIN": {p: True for p in CLIENT_PERMISSIONS}, "CLIENT_ADMIN": {
# Funcionales — todo ON
"view_tickets": True,
"create_tickets": True,
"close_tickets": True,
"view_reports": True,
"manage_tenant_users": True,
"create_issues": True,
# Navegación cliente — todo ON
"nav:tickets": True,
"nav:organization": True,
"nav:usuarios": True,
},
"CLIENT_USER": { "CLIENT_USER": {
# Funcionales
"view_tickets": True, "view_tickets": True,
"create_tickets": True, "create_tickets": True,
"close_tickets": False, "close_tickets": False,
"view_reports": False, "view_reports": False,
"manage_tenant_users": False, "manage_tenant_users": False,
"create_issues": False, "create_issues": False,
} # Navegación cliente
"nav:tickets": True,
"nav:organization": False,
"nav:usuarios": False,
},
"ADMIN": {
# Navegación interna — todo ON
"nav:tickets": True,
"nav:users": True,
"nav:tenants": True,
"nav:categories": True,
"nav:systems": True,
"nav:sla": True,
"nav:audit": True,
"nav:reports": True,
},
"SUPPORT_MANAGER": {
"nav:tickets": True,
"nav:users": True,
"nav:tenants": False,
"nav:categories": True,
"nav:systems": True,
"nav:sla": True,
"nav:audit": True,
"nav:reports": True,
},
"AGENT": {
"nav:tickets": True,
"nav:users": False,
"nav:tenants": False,
"nav:categories": False,
"nav:systems": False,
"nav:sla": False,
"nav:audit": False,
"nav:reports": False,
},
"AUDITOR": {
"nav:tickets": True,
"nav:users": False,
"nav:tenants": False,
"nav:categories": False,
"nav:systems": False,
"nav:sla": False,
"nav:audit": True,
"nav:reports": True,
},
} }

View File

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

View File

@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CLIENT_ROUTES } from '$lib/routes-registry';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { navPerms } from '$lib/stores/navPermissions';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
@@ -8,24 +10,29 @@
let isMenuOpen = false; let isMenuOpen = false;
onMount(() => { onMount(async () => {
auth.init(); await auth.init();
if ($auth.isAuthenticated) {
await navPerms.load();
}
}); });
$: visibleRoutes = CLIENT_ROUTES.filter(r => $navPerms[r.key] === true);
function toggleMenu() { function toggleMenu() {
isMenuOpen = !isMenuOpen; isMenuOpen = !isMenuOpen;
} }
function handleLogout() { function handleLogout() {
isMenuOpen = false; isMenuOpen = false;
auth.logout(); // El store maneja la redirección automática navPerms.clear();
auth.logout();
} }
</script> </script>
<header class="bg-white shadow-sm border-b border-gray-200"> <header class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<!-- Logo -->
{#if showLogo} {#if showLogo}
<div class="flex items-center"> <div class="flex items-center">
<a href="/" class="flex items-center space-x-2"> <a href="/" class="flex items-center space-x-2">
@@ -40,22 +47,16 @@
</div> </div>
{/if} {/if}
<!-- Navigation -->
{#if showNavigation && $auth.isAuthenticated} {#if showNavigation && $auth.isAuthenticated}
<nav class="hidden md:flex space-x-8"> <nav class="hidden md:flex space-x-8">
<a {#each visibleRoutes as route}
href="/tickets" <a
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium" href={route.path}
> class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
Mis Tickets >
</a> {route.label}
</a>
<a {/each}
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
<a <a
href="/tickets/new" href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
@@ -65,7 +66,6 @@
</nav> </nav>
{/if} {/if}
<!-- User menu -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
{#if $auth.isAuthenticated} {#if $auth.isAuthenticated}
<div class="relative"> <div class="relative">
@@ -98,17 +98,13 @@
<a <a
href="/profile" href="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => (isMenuOpen = false)} on:click={() => (isMenuOpen = false)}>Mi Perfil</a
> >
Mi Perfil
</a>
<a <a
href="/organization" href="/organization"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => (isMenuOpen = false)} on:click={() => (isMenuOpen = false)}>Mi Organización</a
> >
Mi Organización
</a>
<button <button
on:click={handleLogout} on:click={handleLogout}
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
@@ -130,23 +126,17 @@
</div> </div>
</div> </div>
<!-- Mobile navigation -->
{#if showNavigation && $auth.isAuthenticated} {#if showNavigation && $auth.isAuthenticated}
<div class="md:hidden border-t border-gray-200 py-2"> <div class="md:hidden border-t border-gray-200 py-2">
<nav class="flex space-x-4"> <nav class="flex space-x-4">
<a {#each visibleRoutes as route}
href="/tickets" <a
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium" href={route.path}
> class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
Mis Tickets >
</a> {route.label}
</a>
<a {/each}
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
<a <a
href="/tickets/new" href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
@@ -159,7 +149,6 @@
</div> </div>
</header> </header>
<!-- Backdrop for mobile menu -->
{#if isMenuOpen} {#if isMenuOpen}
<div class="fixed inset-0 z-40 md:hidden" on:click={() => (isMenuOpen = false)} /> <div class="fixed inset-0 z-40 md:hidden" on:click={() => (isMenuOpen = false)} />
{/if} {/if}

View File

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

View File

@@ -0,0 +1,11 @@
export interface RouteDefinition {
key: string;
label: string;
path: string;
}
export const CLIENT_ROUTES: RouteDefinition[] = [
{ key: 'nav:tickets', label: 'Mis Tickets', path: '/tickets' },
{ key: 'nav:organization', label: 'Organización', path: '/organization' },
{ key: 'nav:usuarios', label: 'Usuarios', path: '/usuarios' },
]

View File

@@ -1,7 +1,6 @@
import { writable } from 'svelte/store';
import { auth } from './auth.js';
import { get } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { auth } from './auth.js';
// Types // Types
export interface Category { export interface Category {
@@ -60,15 +59,15 @@ function createAppStore() {
// Load categories // Load categories
loadCategories: async () => { loadCategories: async () => {
update(state => ({ ...state, isLoading: true, error: null })); update(state => ({ ...state, isLoading: true, error: null }));
try { try {
const categories = await apiCall('/categories/'); const categories = await apiCall('/categories/');
update(state => ({ ...state, categories, isLoading: false })); update(state => ({ ...state, categories, isLoading: false }));
} catch (error) { } catch (error) {
update(state => ({ update(state => ({
...state, ...state,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load categories' error: error instanceof Error ? error.message : 'Failed to load categories'
})); }));
} }
}, },

View File

@@ -0,0 +1,36 @@
import { auth } from '$lib/stores/auth';
import { get, writable } from 'svelte/store';
function createNavPermissionsStore() {
const { subscribe, set } = writable<Record<string, boolean>>({});
return {
subscribe,
load: async () => {
try {
const authState = get(auth);
const slug = authState.user?.tenant_slug || authState.user?.tenant_id || '';
const headers: Record<string, string> = {
'X-App': 'client',
'X-Tenant-Slug': slug,
};
if (authState.token) {
headers['Authorization'] = `Bearer ${authState.token}`;
}
const res = await fetch('/api/v1/permissions/my', {
credentials: 'include',
headers
});
if (res.ok) {
const perms = await res.json();
set(perms);
}
} catch (e) {
set({});
}
},
clear: () => set({})
};
}
export const navPerms = createNavPermissionsStore();

View File

@@ -2,6 +2,7 @@ import { auth } from '$lib/stores/auth';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
interface RequestOptions extends RequestInit { interface RequestOptions extends RequestInit {
params?: Record<string, string>; params?: Record<string, string>;
} }
@@ -65,7 +66,6 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
if (authState.token) { if (authState.token) {
headers.set('Authorization', `Bearer ${authState.token}`); headers.set('Authorization', `Bearer ${authState.token}`);
} }
}
headers.set('X-App', 'client'); headers.set('X-App', 'client');
const slug = get(auth)?.user?.tenant_slug || get(auth)?.user?.tenant_id || ''; const slug = get(auth)?.user?.tenant_slug || get(auth)?.user?.tenant_id || '';
headers.set('X-Tenant-Slug', slug); headers.set('X-Tenant-Slug', slug);
@@ -108,5 +108,7 @@ export const api = {
delete: <T>(endpoint: string) => delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }), request<T>(endpoint, { method: 'DELETE' }),
downloadFile: (endpoint: string, filename: string) => downloadFile: (endpoint: string, filename: string) =>
downloadFile(endpoint, filename) downloadFile(endpoint, filename),
postForm: <T>(endpoint: string, body: FormData) =>
request<T>(endpoint, { method: 'POST', body })
}; };

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation'; import { onMount } from 'svelte';
let profile: any = null; let profile: any = null;
let isLoading = true; let isLoading = true;
@@ -90,9 +90,8 @@
function cancelEdit() { function cancelEdit() {
for (const key of Object.keys(form)) { for (const key of Object.keys(form)) {
(form as any)[key] = (profile?.[key] !== undefined && profile?.[key] !== null) (form as any)[key] =
? profile[key] profile?.[key] !== undefined && profile?.[key] !== null ? profile[key] : '';
: '';
} }
isEditing = false; isEditing = false;
} }
@@ -114,11 +113,7 @@
<p class="text-gray-600 mt-1">Información empresarial de tu organización</p> <p class="text-gray-600 mt-1">Información empresarial de tu organización</p>
</div> </div>
{#if !isEditing && !isLoading} {#if !isEditing && !isLoading}
<button <button type="button" class="btn-primary px-4 py-2" on:click={() => (isEditing = true)}>
type="button"
class="btn-primary px-4 py-2"
on:click={() => (isEditing = true)}
>
Editar información Editar información
</button> </button>
{/if} {/if}
@@ -126,12 +121,11 @@
{#if isLoading} {#if isLoading}
<div class="text-center py-16"> <div class="text-center py-16">
<div class="spinner w-8 h-8 mx-auto mb-4"></div> <div class="spinner w-8 h-8 mx-auto mb-4" />
<p class="text-gray-500">Cargando información de la organización...</p> <p class="text-gray-500">Cargando información de la organización...</p>
</div> </div>
{:else} {:else}
<form on:submit|preventDefault={saveProfile} class="space-y-8"> <form on:submit|preventDefault={saveProfile} class="space-y-8">
<!-- Información general --> <!-- Información general -->
<div class="card"> <div class="card">
<div class="border-b border-gray-200 px-6 py-4"> <div class="border-b border-gray-200 px-6 py-4">
@@ -139,7 +133,6 @@
</div> </div>
<div class="px-6 py-5"> <div class="px-6 py-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div> <div>
<label class="form-label">Razón social</label> <label class="form-label">Razón social</label>
{#if isEditing} {#if isEditing}
@@ -161,7 +154,14 @@
<div> <div>
<label class="form-label">RFC</label> <label class="form-label">RFC</label>
{#if isEditing} {#if isEditing}
<input type="text" class="form-input" style="text-transform:uppercase" bind:value={form.rfc} maxlength="13" placeholder="XAXX010101000" /> <input
type="text"
class="form-input"
style="text-transform:uppercase"
bind:value={form.rfc}
maxlength="13"
placeholder="XAXX010101000"
/>
{:else} {:else}
<p class="text-sm font-mono text-gray-900 mt-1">{val('rfc') || '—'}</p> <p class="text-sm font-mono text-gray-900 mt-1">{val('rfc') || '—'}</p>
{/if} {/if}
@@ -194,18 +194,25 @@
<div> <div>
<label class="form-label">Sitio web</label> <label class="form-label">Sitio web</label>
{#if isEditing} {#if isEditing}
<input type="url" class="form-input" bind:value={form.website} placeholder="https://..." /> <input
type="url"
class="form-input"
bind:value={form.website}
placeholder="https://..."
/>
{:else if val('website')}
<p class="text-sm mt-1">
<a
href={val('website')}
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:underline">{val('website')}</a
>
</p>
{:else} {:else}
{#if val('website')} <p class="text-sm text-gray-400 mt-1"></p>
<p class="text-sm mt-1">
<a href={val('website')} target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:underline">{val('website')}</a>
</p>
{:else}
<p class="text-sm text-gray-400 mt-1"></p>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -217,7 +224,6 @@
</div> </div>
<div class="px-6 py-5"> <div class="px-6 py-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div> <div>
<label class="form-label">País</label> <label class="form-label">País</label>
{#if isEditing} {#if isEditing}
@@ -248,7 +254,12 @@
<div> <div>
<label class="form-label">Código postal</label> <label class="form-label">Código postal</label>
{#if isEditing} {#if isEditing}
<input type="text" class="form-input" bind:value={form.postal_code} maxlength="10" /> <input
type="text"
class="form-input"
bind:value={form.postal_code}
maxlength="10"
/>
{:else} {:else}
<p class="text-sm text-gray-900 mt-1">{val('postal_code') || '—'}</p> <p class="text-sm text-gray-900 mt-1">{val('postal_code') || '—'}</p>
{/if} {/if}
@@ -262,7 +273,6 @@
<p class="text-sm text-gray-900 mt-1">{val('address') || '—'}</p> <p class="text-sm text-gray-900 mt-1">{val('address') || '—'}</p>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -274,7 +284,6 @@
</div> </div>
<div class="px-6 py-5"> <div class="px-6 py-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div> <div>
<label class="form-label">Teléfono principal</label> <label class="form-label">Teléfono principal</label>
{#if isEditing} {#if isEditing}
@@ -296,12 +305,16 @@
<div> <div>
<label class="form-label">Horario de atención</label> <label class="form-label">Horario de atención</label>
{#if isEditing} {#if isEditing}
<input type="text" class="form-input" bind:value={form.business_hours} placeholder="Lun-Vie 9:00-18:00" /> <input
type="text"
class="form-input"
bind:value={form.business_hours}
placeholder="Lun-Vie 9:00-18:00"
/>
{:else} {:else}
<p class="text-sm text-gray-900 mt-1">{val('business_hours') || '—'}</p> <p class="text-sm text-gray-900 mt-1">{val('business_hours') || '—'}</p>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -318,13 +331,11 @@
rows="4" rows="4"
bind:value={form.notes} bind:value={form.notes}
placeholder="Información adicional sobre la organización..." placeholder="Información adicional sobre la organización..."
></textarea> />
{:else if val('notes')}
<p class="text-sm text-gray-900 whitespace-pre-wrap">{val('notes')}</p>
{:else} {:else}
{#if val('notes')} <p class="text-sm text-gray-400">Sin notas</p>
<p class="text-sm text-gray-900 whitespace-pre-wrap">{val('notes')}</p>
{:else}
<p class="text-sm text-gray-400">Sin notas</p>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@@ -336,13 +347,9 @@
type="button" type="button"
class="btn-secondary px-5 py-2" class="btn-secondary px-5 py-2"
on:click={cancelEdit} on:click={cancelEdit}
disabled={isSaving} disabled={isSaving}>Cancelar</button
>Cancelar</button>
<button
type="submit"
class="btn-primary px-5 py-2"
disabled={isSaving}
> >
<button type="submit" class="btn-primary px-5 py-2" disabled={isSaving}>
{isSaving ? 'Guardando...' : 'Guardar cambios'} {isSaving ? 'Guardando...' : 'Guardar cambios'}
</button> </button>
</div> </div>

View File

@@ -992,19 +992,48 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if $auth.user?.is_two_factor_enabled} {#if $auth.user?.is_two_factor_enabled}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800"> <span
<svg class="w-3.5 h-3.5 mr-1" 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> class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
<svg
class="w-3.5 h-3.5 mr-1"
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
>
Habilitado Habilitado
</span> </span>
<button <button
type="button" type="button"
class="text-sm text-red-600 hover:text-red-800 font-medium" class="text-sm text-red-600 hover:text-red-800 font-medium"
on:click={() => { show2faDisable = !show2faDisable; disableTotpCode = ''; }} on:click={() => {
disabled={is2faLoading} show2faDisable = !show2faDisable;
>Deshabilitar</button> disableTotpCode = '';
}}
disabled={is2faLoading}>Deshabilitar</button
>
{:else} {:else}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600"> <span
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600"
>
<svg
class="w-3.5 h-3.5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
Deshabilitado Deshabilitado
</span> </span>
<button <button
@@ -1021,11 +1050,21 @@
{#if show2faSetup && qrUri} {#if show2faSetup && qrUri}
<div class="p-5 border-t border-gray-200 space-y-4"> <div class="p-5 border-t border-gray-200 space-y-4">
<p class="text-sm font-medium text-gray-700">1. Escanea este código QR en Google Authenticator, Authy o cualquier app TOTP:</p> <p class="text-sm font-medium text-gray-700">
1. Escanea este código QR en Google Authenticator, Authy o cualquier app TOTP:
</p>
<div class="flex justify-center bg-white p-4 border border-gray-200 rounded"> <div class="flex justify-center bg-white p-4 border border-gray-200 rounded">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={encodeURIComponent(qrUri)}" alt="Código QR 2FA" class="w-44 h-44" /> <img
src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={encodeURIComponent(
qrUri
)}"
alt="Código QR 2FA"
class="w-44 h-44"
/>
</div> </div>
<p class="text-sm font-medium text-gray-700 mt-3">2. Ingresa el código de 6 dígitos para confirmar:</p> <p class="text-sm font-medium text-gray-700 mt-3">
2. Ingresa el código de 6 dígitos para confirmar:
</p>
<div class="flex gap-3"> <div class="flex gap-3">
<input <input
type="text" type="text"
@@ -1045,32 +1084,45 @@
<button <button
type="button" type="button"
class="text-gray-500 hover:text-gray-700 text-sm font-medium" class="text-gray-500 hover:text-gray-700 text-sm font-medium"
on:click={() => { show2faSetup = false; }} on:click={() => {
>Cancelar</button> show2faSetup = false;
}}>Cancelar</button
>
</div> </div>
</div> </div>
{/if} {/if}
{#if showBackupCodes && backupCodes.length > 0} {#if showBackupCodes && backupCodes.length > 0}
<div class="p-5 border-t border-green-200 bg-green-50"> <div class="p-5 border-t border-green-200 bg-green-50">
<h4 class="font-medium text-green-900 mb-2">✅ 2FA activado — Guarda tus códigos de respaldo</h4> <h4 class="font-medium text-green-900 mb-2">
<p class="text-sm text-green-700 mb-3">Estos códigos son de un solo uso. Guárdalos en un lugar seguro.</p> ✅ 2FA activado — Guarda tus códigos de respaldo
</h4>
<p class="text-sm text-green-700 mb-3">
Estos códigos son de un solo uso. Guárdalos en un lugar seguro.
</p>
<div class="grid grid-cols-2 gap-2 font-mono text-sm"> <div class="grid grid-cols-2 gap-2 font-mono text-sm">
{#each backupCodes as code} {#each backupCodes as code}
<span class="bg-white border border-green-200 px-3 py-1.5 rounded text-center">{code}</span> <span class="bg-white border border-green-200 px-3 py-1.5 rounded text-center"
>{code}</span
>
{/each} {/each}
</div> </div>
<button <button
type="button" type="button"
class="mt-4 text-sm text-green-700 underline" class="mt-4 text-sm text-green-700 underline"
on:click={() => { showBackupCodes = false; backupCodes = []; }} on:click={() => {
>He guardado mis códigos</button> showBackupCodes = false;
backupCodes = [];
}}>He guardado mis códigos</button
>
</div> </div>
{/if} {/if}
{#if show2faDisable} {#if show2faDisable}
<div class="p-5 border-t border-red-200 bg-red-50"> <div class="p-5 border-t border-red-200 bg-red-50">
<p class="text-sm font-medium text-red-800 mb-3">Ingresa el código de tu app autenticadora para deshabilitar 2FA:</p> <p class="text-sm font-medium text-red-800 mb-3">
Ingresa el código de tu app autenticadora para deshabilitar 2FA:
</p>
<div class="flex gap-3"> <div class="flex gap-3">
<input <input
type="text" type="text"
@@ -1090,8 +1142,10 @@
<button <button
type="button" type="button"
class="text-gray-500 hover:text-gray-700 text-sm" class="text-gray-500 hover:text-gray-700 text-sm"
on:click={() => { show2faDisable = false; }} on:click={() => {
>Cancelar</button> show2faDisable = false;
}}>Cancelar</button
>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -1,47 +1,46 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import TicketCard from '$lib/components/TicketCard.svelte'; import TicketCard from '$lib/components/TicketCard.svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { onMount } from 'svelte';
let searchQuery = ''; let searchQuery = '';
let statusFilter = ''; let statusFilter = '';
let priorityFilter = ''; let priorityFilter = '';
let filteredTickets: any[] = []; let filteredTickets: any[] = [];
onMount(() => { onMount(async () => {
// Redirect if not authenticated await auth.init();
if (!$auth.isAuthenticated) { if (!$auth.isAuthenticated) {
goto('/login'); goto('/login');
return; return;
} }
// Load tickets
tickets.loadTickets(); tickets.loadTickets();
}); });
// Filter tickets based on search and filters // Filter tickets based on search and filters
$: { $: {
filteredTickets = $tickets.tickets.filter(ticket => { filteredTickets = $tickets.tickets.filter(ticket => {
const matchesSearch = !searchQuery || const matchesSearch =
!searchQuery ||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase()) || ticket.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
ticket.description.toLowerCase().includes(searchQuery.toLowerCase()) || ticket.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
ticket.id.toLowerCase().includes(searchQuery.toLowerCase()); ticket.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || ticket.status === statusFilter; const matchesStatus = !statusFilter || ticket.status === statusFilter;
const matchesPriority = !priorityFilter || ticket.priority === priorityFilter; const matchesPriority = !priorityFilter || ticket.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesPriority; return matchesSearch && matchesStatus && matchesPriority;
}); });
} }
// Get status counts // Get status counts
$: statusCounts = $tickets.tickets.reduce((acc, ticket) => { $: statusCounts = $tickets.tickets.reduce((acc, ticket) => {
acc[ticket.status] = (acc[ticket.status] || 0) + 1; acc[ticket.status] = (acc[ticket.status] || 0) + 1;
return acc; return acc;
}, {} as Record<string, number>); }, {} as Record<string, number>);
function clearFilters() { function clearFilters() {
searchQuery = ''; searchQuery = '';
statusFilter = ''; statusFilter = '';
@@ -58,29 +57,39 @@
<div class="flex justify-between items-center mb-8"> <div class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Mis Tickets</h1> <h1 class="text-3xl font-bold text-gray-900">Mis Tickets</h1>
<p class="text-gray-600 mt-2"> <p class="text-gray-600 mt-2">Gestiona y da seguimiento a todos tus tickets de soporte</p>
Gestiona y da seguimiento a todos tus tickets de soporte
</p>
</div> </div>
<a <a href="/tickets/new" class="btn-primary px-4 py-2 inline-flex items-center space-x-2">
href="/tickets/new"
class="btn-primary px-4 py-2 inline-flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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 6v6m0 0v6m0-6h6m-6 0H6" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg> </svg>
<span>Crear Ticket</span> <span>Crear Ticket</span>
</a> </a>
</div> </div>
<!-- Stats --> <!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> class="w-4 h-4 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3">
@@ -90,13 +99,23 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> class="w-4 h-4 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3">
@@ -108,13 +127,23 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> class="w-4 h-4 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3">
@@ -126,13 +155,23 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> class="w-4 h-4 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> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3">
@@ -145,7 +184,7 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="card mb-8"> <div class="card mb-8">
<div class="card-content"> <div class="card-content">
@@ -160,14 +199,10 @@
bind:value={searchQuery} bind:value={searchQuery}
/> />
</div> </div>
<div> <div>
<label for="status-filter" class="form-label">Estado</label> <label for="status-filter" class="form-label">Estado</label>
<select <select id="status-filter" class="form-input" bind:value={statusFilter}>
id="status-filter"
class="form-input"
bind:value={statusFilter}
>
<option value="">Todos los estados</option> <option value="">Todos los estados</option>
<option value="NEW">Nuevo</option> <option value="NEW">Nuevo</option>
<option value="IN_PROGRESS">En Progreso</option> <option value="IN_PROGRESS">En Progreso</option>
@@ -177,14 +212,10 @@
<option value="REOPENED">Reabierto</option> <option value="REOPENED">Reabierto</option>
</select> </select>
</div> </div>
<div> <div>
<label for="priority-filter" class="form-label">Prioridad</label> <label for="priority-filter" class="form-label">Prioridad</label>
<select <select id="priority-filter" class="form-input" bind:value={priorityFilter}>
id="priority-filter"
class="form-input"
bind:value={priorityFilter}
>
<option value="">Todas las prioridades</option> <option value="">Todas las prioridades</option>
<option value="LOW">Baja</option> <option value="LOW">Baja</option>
<option value="MEDIUM">Media</option> <option value="MEDIUM">Media</option>
@@ -192,39 +223,38 @@
<option value="URGENT">Urgente</option> <option value="URGENT">Urgente</option>
</select> </select>
</div> </div>
<div class="flex items-end"> <div class="flex items-end">
<button <button on:click={clearFilters} class="btn-secondary px-4 py-2 w-full">
on:click={clearFilters}
class="btn-secondary px-4 py-2 w-full"
>
Limpiar Filtros Limpiar Filtros
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Tickets List --> <!-- Tickets List -->
<div class="space-y-6"> <div class="space-y-6">
{#if $tickets.isLoading} {#if $tickets.isLoading}
<div class="text-center py-12"> <div class="text-center py-12">
<div class="spinner w-8 h-8 mx-auto mb-4"></div> <div class="spinner w-8 h-8 mx-auto mb-4" />
<p class="text-gray-600">Cargando tickets...</p> <p class="text-gray-600">Cargando tickets...</p>
</div> </div>
{:else if $tickets.error} {:else if $tickets.error}
<div class="text-center py-12"> <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"> <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"> <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" /> <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> </svg>
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar tickets</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar tickets</h3>
<p class="text-gray-600 mb-4">{$tickets.error}</p> <p class="text-gray-600 mb-4">{$tickets.error}</p>
<button <button on:click={() => tickets.loadTickets()} class="btn-primary px-4 py-2">
on:click={() => tickets.loadTickets()}
class="btn-primary px-4 py-2"
>
Reintentar Reintentar
</button> </button>
</div> </div>
@@ -232,25 +262,26 @@
<div class="text-center py-12"> <div class="text-center py-12">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4"> <div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg> </svg>
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2"> <h3 class="text-lg font-medium text-gray-900 mb-2">
{$tickets.tickets.length === 0 ? 'No tienes tickets' : 'No se encontraron tickets'} {$tickets.tickets.length === 0 ? 'No tienes tickets' : 'No se encontraron tickets'}
</h3> </h3>
<p class="text-gray-600 mb-4"> <p class="text-gray-600 mb-4">
{$tickets.tickets.length === 0 {$tickets.tickets.length === 0
? 'Crea tu primer ticket para comenzar' ? 'Crea tu primer ticket para comenzar'
: 'Intenta ajustar los filtros de búsqueda'} : 'Intenta ajustar los filtros de búsqueda'}
</p> </p>
{#if $tickets.tickets.length === 0} {#if $tickets.tickets.length === 0}
<a href="/tickets/new" class="btn-primary px-4 py-2"> <a href="/tickets/new" class="btn-primary px-4 py-2"> Crear Ticket </a>
Crear Ticket
</a>
{:else} {:else}
<button on:click={clearFilters} class="btn-secondary px-4 py-2"> <button on:click={clearFilters} class="btn-secondary px-4 py-2"> Limpiar Filtros </button>
Limpiar Filtros
</button>
{/if} {/if}
</div> </div>
{:else} {:else}
@@ -259,7 +290,7 @@
<TicketCard {ticket} /> <TicketCard {ticket} />
{/each} {/each}
</div> </div>
{#if filteredTickets.length !== $tickets.tickets.length} {#if filteredTickets.length !== $tickets.tickets.length}
<div class="text-center py-4 text-sm text-gray-500"> <div class="text-center py-4 text-sm text-gray-500">
Mostrando {filteredTickets.length} de {$tickets.tickets.length} tickets Mostrando {filteredTickets.length} de {$tickets.tickets.length} tickets
@@ -267,4 +298,4 @@
{/if} {/if}
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import IssueDetailModal from '$lib/components/IssueDetailModal.svelte';
import IssueModal from '$lib/components/IssueModal.svelte';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js'; import { tickets } from '$lib/stores/tickets.js';
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation'; import { onMount } from 'svelte';
import IssueModal from '$lib/components/IssueModal.svelte';
let ticketId: string; let ticketId: string;
let newComment = ''; let newComment = '';
@@ -17,6 +18,7 @@
let isUploading = false; let isUploading = false;
let showAttachments = true; let showAttachments = true;
let showIssueModal = false; let showIssueModal = false;
let selectedIssue = null;
let pollingInterval: any = null; let pollingInterval: any = null;
async function loadComments() { async function loadComments() {
@@ -35,15 +37,22 @@
ticketId = $page.params.id; ticketId = $page.params.id;
if (ticketId) { if (ticketId) {
tickets.loadTicket(ticketId); tickets.loadTicket(ticketId);
pollingInterval = setInterval(() => { loadComments(); }, 3000); pollingInterval = setInterval(() => {
loadComments();
}, 3000);
} }
return () => { if (pollingInterval) clearInterval(pollingInterval); }; return () => {
if (pollingInterval) clearInterval(pollingInterval);
};
}); });
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString('es-ES', { return new Date(dateString).toLocaleString('es-ES', {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit',
hour: '2-digit', minute: '2-digit' month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}); });
} }
@@ -90,8 +99,13 @@
return; return;
} }
const allowedTypes = [ const allowedTypes = [
'image/jpeg','image/png','image/gif','image/webp','application/pdf', 'image/jpeg',
'text/plain','application/msword', 'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@@ -122,7 +136,9 @@
} }
} }
function handleCloseTicket() { showCloseDialog = true; } function handleCloseTicket() {
showCloseDialog = true;
}
async function confirmCloseTicket() { async function confirmCloseTicket() {
isClosingTicket = true; isClosingTicket = true;
@@ -190,10 +206,15 @@
{$tickets.currentTicket.title} {$tickets.currentTicket.title}
</h1> </h1>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<span class={(statusConfig[$tickets.currentTicket.status] ?? fallbackStatus).class}> <span
class={(statusConfig[$tickets.currentTicket.status] ?? fallbackStatus).class}
>
{(statusConfig[$tickets.currentTicket.status] ?? fallbackStatus).label} {(statusConfig[$tickets.currentTicket.status] ?? fallbackStatus).label}
</span> </span>
<span class={(priorityConfig[$tickets.currentTicket.priority] ?? fallbackPriority).class}> <span
class={(priorityConfig[$tickets.currentTicket.priority] ?? fallbackPriority)
.class}
>
{(priorityConfig[$tickets.currentTicket.priority] ?? fallbackPriority).label} {(priorityConfig[$tickets.currentTicket.priority] ?? fallbackPriority).label}
</span> </span>
<span class="text-sm text-gray-500"> <span class="text-sm text-gray-500">
@@ -202,7 +223,11 @@
</div> </div>
</div> </div>
{#if canClose} {#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 Cerrar Ticket
</button> </button>
{/if} {/if}
@@ -213,7 +238,9 @@
{#if $tickets.currentTicket.resolution} {#if $tickets.currentTicket.resolution}
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg"> <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> <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> </div>
{/if} {/if}
</div> </div>
@@ -226,12 +253,16 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2"> <h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<span>Archivos Adjuntos</span> <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"> <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} {$tickets.attachments.length}
</span> </span>
</h3> </h3>
<button class="text-sm text-gray-500 hover:text-gray-700" <button
on:click={() => (showAttachments = !showAttachments)}> class="text-sm text-gray-500 hover:text-gray-700"
on:click={() => (showAttachments = !showAttachments)}
>
{showAttachments ? 'Ocultar' : 'Ver'} archivos {showAttachments ? 'Ocultar' : 'Ver'} archivos
</button> </button>
</div> </div>
@@ -239,17 +270,28 @@
{#if showAttachments} {#if showAttachments}
<div class="card-content space-y-3"> <div class="card-content space-y-3">
{#each $tickets.attachments as attachment} {#each $tickets.attachments as attachment}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100"> <div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100"
>
<div> <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"> <p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB • {attachment.uploaded_by_name} {Math.round(attachment.size_bytes / 1024)} KB • {attachment.uploaded_by_name}
</p> </p>
</div> </div>
<button on:click={() => handleDownloadAttachment(attachment)} class="btn-ghost p-2"> <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"> <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" <path
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" /> 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> </svg>
</button> </button>
</div> </div>
@@ -271,9 +313,14 @@
<div class="space-y-4"> <div class="space-y-4">
{#each $tickets.comments as comment} {#each $tickets.comments as comment}
<div class="flex space-x-3"> <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"> <span class="text-primary-600 text-xs font-medium">
{comment.author_name?.split(' ').map(n => n[0]).join('') ?? '??'} {comment.author_name
?.split(' ')
.map(n => n[0])
.join('') ?? '??'}
</span> </span>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -281,7 +328,9 @@
<span class="text-sm font-medium text-gray-900">{comment.author_name}</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> <span class="text-xs text-gray-500">{formatDate(comment.created_at)}</span>
{#if comment.is_internal} {#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} {/if}
</div> </div>
<p class="text-gray-700 whitespace-pre-wrap">{comment.content}</p> <p class="text-gray-700 whitespace-pre-wrap">{comment.content}</p>
@@ -301,25 +350,40 @@
/> />
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
<input type="file" bind:this={fileInput} on:change={handleFileUpload} <input
type="file"
bind:this={fileInput}
on:change={handleFileUpload}
class="hidden" class="hidden"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx" accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx"
disabled={isUploading} /> disabled={isUploading}
<button type="button" on:click={() => fileInput.click()} />
class="btn-ghost p-2 flex items-center space-x-2" disabled={isUploading}> <button
type="button"
on:click={() => fileInput.click()}
class="btn-ghost p-2 flex items-center space-x-2"
disabled={isUploading}
>
{#if isUploading} {#if isUploading}
<div class="spinner w-4 h-4" /> <div class="spinner w-4 h-4" />
{:else} {:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" <path
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" /> 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> </svg>
{/if} {/if}
<span class="text-sm">Adjuntar</span> <span class="text-sm">Adjuntar</span>
</button> </button>
</div> </div>
<button on:click={handleAddComment} class="btn-primary px-4 py-2" <button
disabled={isSubmittingComment || !newComment.trim()}> on:click={handleAddComment}
class="btn-primary px-4 py-2"
disabled={isSubmittingComment || !newComment.trim()}
>
{#if isSubmittingComment} {#if isSubmittingComment}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" /> <div class="spinner w-4 h-4" />
@@ -345,11 +409,15 @@
<div class="card-content space-y-4"> <div class="card-content space-y-4">
<div> <div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt> <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>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Categoría</dt> <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> </div>
{#if $tickets.currentTicket.assigned_to_name} {#if $tickets.currentTicket.assigned_to_name}
<div> <div>
@@ -371,10 +439,13 @@
<dt class="text-sm font-medium text-gray-500 mb-2">Tiempos de SLA</dt> <dt class="text-sm font-medium text-gray-500 mb-2">Tiempos de SLA</dt>
{#if $tickets.currentTicket.sla_response_due} {#if $tickets.currentTicket.sla_response_due}
{@const respDue = new Date($tickets.currentTicket.sla_response_due)} {@const respDue = new Date($tickets.currentTicket.sla_response_due)}
{@const respVencido = respDue < new Date() && !$tickets.currentTicket.first_response_at} {@const respVencido =
respDue < new Date() && !$tickets.currentTicket.first_response_at}
<div class="mb-2"> <div class="mb-2">
<dt class="text-xs text-gray-400">Respuesta límite</dt> <dt class="text-xs text-gray-400">Respuesta límite</dt>
<dd class="text-sm {respVencido ? 'text-red-600 font-medium' : 'text-gray-900'}"> <dd
class="text-sm {respVencido ? 'text-red-600 font-medium' : 'text-gray-900'}"
>
{formatDate($tickets.currentTicket.sla_response_due)} {formatDate($tickets.currentTicket.sla_response_due)}
{#if respVencido} {#if respVencido}
<span class="block text-xs text-red-500">¡Vencido!</span> <span class="block text-xs text-red-500">¡Vencido!</span>
@@ -410,21 +481,46 @@
<h3 class="text-lg font-semibold text-gray-900"> <h3 class="text-lg font-semibold text-gray-900">
Asuntos Asuntos
{#if $tickets.issues.length > 0} {#if $tickets.issues.length > 0}
<span class="text-gray-400 font-normal text-sm ml-1">({$tickets.issues.length})</span> <span class="text-gray-400 font-normal text-sm ml-1"
>({$tickets.issues.length})</span
>
{/if} {/if}
</h3> </h3>
<button type="button" on:click={() => showIssueModal = true} <button
class="text-sm text-blue-600 hover:text-blue-700 font-medium"> type="button"
on:click={() => (showIssueModal = true)}
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
+ Crear + Crear
</button> </button>
</div> </div>
{#if $tickets.issues.length > 0} {#if $tickets.issues.length > 0}
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{#each $tickets.issues as issue} {#each $tickets.issues as issue}
<div class="card-content py-3"> <button
<p class="text-sm text-gray-700">{issue.content}</p> type="button"
<span class="text-xs text-gray-400 mt-1 block">{issue.priority} · {issue.created_by_name}</span> on:click={() => (selectedIssue = issue)}
</div> class="w-full text-left card-content py-3 hover:bg-gray-50 transition-colors"
>
<div class="flex items-start justify-between gap-2">
<p class="text-sm text-gray-700 line-clamp-2">{issue.content}</p>
<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>
{#if issue.tagged_users?.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each issue.tagged_users as u}
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full"
>{u.full_name}</span
>
{/each}
</div>
{/if}
<p class="text-xs text-gray-400 mt-1">{issue.created_by_name}</p>
</button>
{/each} {/each}
</div> </div>
{:else} {:else}
@@ -438,38 +534,71 @@
{/if} {/if}
</div> </div>
<!-- Issue Modal --> <!-- Modals -->
{#if showIssueModal && $tickets.currentTicket} {#if showIssueModal && $tickets.currentTicket}
<IssueModal <IssueModal
ticketId={$tickets.currentTicket.id} ticketId={$tickets.currentTicket.id}
on:close={() => showIssueModal = false} on:close={() => (showIssueModal = false)}
on:created={() => showIssueModal = false} on:created={() => {
showIssueModal = false;
tickets.loadTicket(ticketId);
}}
/>
{/if}
{#if selectedIssue && $tickets.currentTicket}
<IssueDetailModal
issue={selectedIssue}
ticketId={$tickets.currentTicket.id}
on:close={() => (selectedIssue = null)}
on:updated={e => {
selectedIssue = e.detail;
}}
on:deleted={() => {
selectedIssue = null;
tickets.loadTicket(ticketId);
}}
/> />
{/if} {/if}
<!-- Close Ticket Dialog -->
{#if showCloseDialog} {#if showCloseDialog}
<div class="fixed inset-0 z-50 overflow-y-auto"> <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"> <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" <div
role="dialog" tabindex="0" class="fixed inset-0 bg-gray-500 opacity-75"
role="dialog"
tabindex="0"
on:click={cancelCloseTicket} on:click={cancelCloseTicket}
on:keydown={e => e.key === 'Escape' && cancelCloseTicket()} /> 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"> <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> <h3 class="text-lg font-medium text-gray-900 mb-2">Cerrar Ticket</h3>
<p class="text-sm text-gray-500 mb-4"> <p class="text-sm text-gray-500 mb-4">¿Estás seguro de que quieres cerrar este ticket?</p>
¿Estás seguro de que quieres cerrar este ticket? <textarea
</p> rows="3"
<textarea rows="3" class="form-input w-full mb-4" class="form-input w-full mb-4"
placeholder="Comentario de cierre (opcional)..." placeholder="Comentario de cierre (opcional)..."
bind:value={closeResolution} disabled={isClosingTicket} /> bind:value={closeResolution}
disabled={isClosingTicket}
/>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button class="btn-secondary px-4 py-2" disabled={isClosingTicket} on:click={cancelCloseTicket}> <button
class="btn-secondary px-4 py-2"
disabled={isClosingTicket}
on:click={cancelCloseTicket}
>
Cancelar Cancelar
</button> </button>
<button class="btn-success px-4 py-2 disabled:opacity-50" disabled={isClosingTicket} on:click={confirmCloseTicket}> <button
class="btn-success px-4 py-2 disabled:opacity-50"
disabled={isClosingTicket}
on:click={confirmCloseTicket}
>
{#if isClosingTicket} {#if isClosingTicket}
<div class="flex items-center gap-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} {:else}
Cerrar Ticket Cerrar Ticket
{/if} {/if}
@@ -478,4 +607,4 @@
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { goto } from '$app/navigation';
import { CLIENT_ROUTES } from '$lib/routes-registry';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation'; import { onMount } from 'svelte';
// ---- Tipos ----
interface User { interface User {
id: string; id: string;
email: string; email: string;
@@ -17,7 +17,6 @@
can_manage_users?: boolean; can_manage_users?: boolean;
} }
// ---- Estado ----
let users: User[] = []; let users: User[] = [];
let isLoading = true; let isLoading = true;
let showModal = false; let showModal = false;
@@ -27,6 +26,8 @@
let searchQuery = ''; let searchQuery = '';
let filterRole = ''; let filterRole = '';
let filterStatus = ''; let filterStatus = '';
let navPermissions: Record<string, boolean> = {};
let isLoadingPerms = false;
let form = { let form = {
first_name: '', first_name: '',
@@ -41,22 +42,47 @@
let isSaving = false; let isSaving = false;
let isDeleting = false; let isDeleting = false;
// ---- Permisos ----
$: currentRole = $auth.user?.role ?? ''; $: currentRole = $auth.user?.role ?? '';
$: isClientAdmin = currentRole === 'CLIENT_ADMIN'; $: isClientAdmin = currentRole === 'CLIENT_ADMIN';
$: canManageUsers = isClientAdmin; $: canManageUsers = isClientAdmin;
// ---- Utilidades ----
function apiHeaders(): Record<string, string> { function apiHeaders(): Record<string, string> {
const h: Record<string, string> = { const h: Record<string, string> = {
'X-App': 'client', 'X-App': 'client',
'X-Tenant-Slug': $auth.user?.tenant_slug || $auth.user?.tenant_id || '', 'X-Tenant-Slug': $auth.user?.tenant_slug || $auth.user?.tenant_id || ''
}; };
if ($auth.token) h['Authorization'] = `Bearer ${$auth.token}`; if ($auth.token) h['Authorization'] = `Bearer ${$auth.token}`;
return h; return h;
} }
// ---- Carga de usuarios ---- function initNavPermissions() {
navPermissions = {};
CLIENT_ROUTES.forEach(r => {
navPermissions[r.key] = false;
});
}
async function loadUserNavPermissions(userId: string) {
isLoadingPerms = true;
try {
const res = await fetch(`/api/v1/permissions/user/${userId}`, {
credentials: 'include',
headers: apiHeaders()
});
if (!res.ok) throw new Error('Error cargando permisos');
const perms = await res.json();
const updated: Record<string, boolean> = {};
CLIENT_ROUTES.forEach(r => {
updated[r.key] = perms[r.key] ?? false;
});
navPermissions = updated;
} catch (e) {
initNavPermissions();
} finally {
isLoadingPerms = false;
}
}
async function loadUsers() { async function loadUsers() {
isLoading = true; isLoading = true;
try { try {
@@ -74,31 +100,42 @@
} }
onMount(async () => { onMount(async () => {
if (!$auth.isAuthenticated) { goto('/login'); return; } if (!$auth.isAuthenticated) {
goto('/login');
return;
}
await loadUsers(); await loadUsers();
}); });
// ---- Filtros reactivos ----
$: filteredUsers = users.filter(u => { $: filteredUsers = users.filter(u => {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
const matchSearch = !q || const matchSearch =
!q ||
u.first_name?.toLowerCase().includes(q) || u.first_name?.toLowerCase().includes(q) ||
u.last_name?.toLowerCase().includes(q) || u.last_name?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q); u.email?.toLowerCase().includes(q);
const matchRole = !filterRole || u.role === filterRole; const matchRole = !filterRole || u.role === filterRole;
const matchStatus = filterStatus === '' ? true : const matchStatus =
filterStatus === 'active' ? u.is_active : !u.is_active; filterStatus === '' ? true : filterStatus === 'active' ? u.is_active : !u.is_active;
return matchSearch && matchRole && matchStatus; return matchSearch && matchRole && matchStatus;
}); });
// ---- Modal ----
function openCreate() { function openCreate() {
modalMode = 'create'; modalMode = 'create';
form = { first_name: '', last_name: '', email: '', password: '', role: 'CLIENT_USER', is_active: true, can_manage_users: false }; form = {
first_name: '',
last_name: '',
email: '',
password: '',
role: 'CLIENT_USER',
is_active: true,
can_manage_users: false
};
initNavPermissions();
showModal = true; showModal = true;
} }
function openEdit(user: User) { async function openEdit(user: User) {
modalMode = 'edit'; modalMode = 'edit';
selectedUser = user; selectedUser = user;
form = { form = {
@@ -110,6 +147,8 @@
is_active: user.is_active, is_active: user.is_active,
can_manage_users: user.can_manage_users || false can_manage_users: user.can_manage_users || false
}; };
initNavPermissions();
await loadUserNavPermissions(user.id);
showModal = true; showModal = true;
} }
@@ -118,16 +157,13 @@
selectedUser = null; selectedUser = null;
} }
// ---- CRUD ----
async function saveUser() { async function saveUser() {
isSaving = true; isSaving = true;
try { try {
const payload: any = { ...form }; const payload: any = { ...form };
if (modalMode === 'edit' && !payload.password) delete payload.password; if (modalMode === 'edit' && !payload.password) delete payload.password;
const url = modalMode === 'create' const url = modalMode === 'create' ? '/api/v1/users/' : `/api/v1/users/${selectedUser!.id}`;
? '/api/v1/users/'
: `/api/v1/users/${selectedUser?.id}`;
const method = modalMode === 'create' ? 'POST' : 'PUT'; const method = modalMode === 'create' ? 'POST' : 'PUT';
const res = await fetch(url, { const res = await fetch(url, {
@@ -138,7 +174,26 @@
}); });
if (!res.ok) throw new Error((await res.json()).detail || 'Error al guardar'); if (!res.ok) throw new Error((await res.json()).detail || 'Error al guardar');
toast.success(modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado'); const savedUser = await res.json();
const userId = modalMode === 'create' ? savedUser.id : selectedUser!.id;
// Guardar permisos de navegación
if (userId) {
try {
await fetch(`/api/v1/permissions/user/${userId}`, {
method: 'PUT',
credentials: 'include',
headers: { ...apiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(navPermissions)
});
} catch (e) {
toast.error('Usuario guardado pero error al guardar permisos de navegación');
}
}
toast.success(
modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado'
);
closeModal(); closeModal();
await loadUsers(); await loadUsers();
} catch (e: any) { } catch (e: any) {
@@ -193,7 +248,6 @@
showDeleteConfirm = true; showDeleteConfirm = true;
} }
// ---- Helpers visuales ----
function roleLabel(role: string): string { function roleLabel(role: string): string {
return role === 'CLIENT_ADMIN' ? 'Administrador' : 'Usuario'; return role === 'CLIENT_ADMIN' ? 'Administrador' : 'Usuario';
} }
@@ -212,9 +266,15 @@
function avatarColor(email: string): string { function avatarColor(email: string): string {
const colors = [ const colors = [
'bg-rose-400', 'bg-orange-400', 'bg-amber-400', 'bg-rose-400',
'bg-emerald-400', 'bg-teal-400', 'bg-cyan-400', 'bg-orange-400',
'bg-blue-400', 'bg-violet-400', 'bg-pink-400' 'bg-amber-400',
'bg-emerald-400',
'bg-teal-400',
'bg-cyan-400',
'bg-blue-400',
'bg-violet-400',
'bg-pink-400'
]; ];
let hash = 0; let hash = 0;
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0); for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
@@ -227,13 +287,11 @@
</svelte:head> </svelte:head>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Encabezado -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900">Usuarios</h1> <h1 class="text-2xl font-bold text-gray-900">Usuarios</h1>
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
Miembros de tu organización· {users.length} en total Miembros de tu organización · {users.length} en total
</p> </p>
</div> </div>
{#if isClientAdmin} {#if isClientAdmin}
@@ -243,79 +301,111 @@
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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 4v16m8-8H4"/> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg> </svg>
Nuevo usuario Nuevo usuario
</button> </button>
{/if} {/if}
</div> </div>
<!-- Filtros -->
<div class="flex flex-col sm:flex-row gap-3 mb-6"> <div class="flex flex-col sm:flex-row gap-3 mb-6">
<div class="relative flex-1"> <div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg> </svg>
<input <input
type="text" type="text"
placeholder="Buscar por nombre o email..." placeholder="Buscar por nombre o email..."
bind:value={searchQuery} bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
/> />
</div> </div>
<select bind:value={filterRole} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"> <select
<option value="">Roles </option> bind:value={filterRole}
class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Roles</option>
<option value="CLIENT_ADMIN">Administrador</option> <option value="CLIENT_ADMIN">Administrador</option>
<option value="CLIENT_USER">Usuario</option> <option value="CLIENT_USER">Usuario</option>
</select> </select>
<select bind:value={filterStatus} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"> <select
<option value="">Estado </option> bind:value={filterStatus}
class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Estado</option>
<option value="active">Activos</option> <option value="active">Activos</option>
<option value="inactive">Inactivos</option> <option value="inactive">Inactivos</option>
</select> </select>
</div> </div>
<!-- Tabla -->
{#if isLoading} {#if isLoading}
<div class="flex items-center justify-center py-24"> <div class="flex items-center justify-center py-24">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div> </div>
{:else if filteredUsers.length === 0} {:else if filteredUsers.length === 0}
<div class="text-center py-20 bg-white rounded-xl border border-gray-100"> <div class="text-center py-20 bg-white rounded-xl border border-gray-100">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-gray-500 font-medium">No se encontraron usuarios</p> <p class="text-gray-500 font-medium">No se encontraron usuarios</p>
<p class="text-sm text-gray-400 mt-1">Intenta ajustar los filtros de búsqueda</p>
</div> </div>
{:else} {:else}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden"> <div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-gray-100 bg-gray-50"> <tr class="border-b border-gray-100 bg-gray-50">
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Usuario</th> <th
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Rol</th> class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider"
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell">Miembro desde</th> >Usuario</th
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Estado</th> >
<th
class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider"
>Rol</th
>
<th
class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell"
>Miembro desde</th
>
<th
class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider"
>Estado</th
>
{#if canManageUsers} {#if canManageUsers}
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">Acciones</th> <th
class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right"
>Acciones</th
>
{/if} {/if}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-50"> <tbody class="divide-y divide-gray-50">
{#each filteredUsers as user (user.id)} {#each filteredUsers as user (user.id)}
<tr class="hover:bg-gray-50/50 transition-colors group"> <tr class="hover:bg-gray-50/50 transition-colors group">
<!-- Avatar + info -->
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0"> <div
class="w-9 h-9 rounded-full {avatarColor(
user.email
)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
>
{initials(user)} {initials(user)}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<p class="font-medium text-gray-900 truncate"> <p class="font-medium text-gray-900 truncate">
{user.first_name || ''} {user.last_name || ''} {user.first_name || ''}
{user.last_name || ''}
{#if user.id === $auth.user?.id} {#if user.id === $auth.user?.id}
<span class="ml-1 text-xs text-gray-400">(tú)</span> <span class="ml-1 text-xs text-gray-400">(tú)</span>
{/if} {/if}
@@ -324,45 +414,42 @@
</div> </div>
</div> </div>
</td> </td>
<!-- Rol -->
<td class="px-6 py-4"> <td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {roleBadgeClass(user.role)}"> <span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {roleBadgeClass(
user.role
)}"
>
{roleLabel(user.role)} {roleLabel(user.role)}
</span> </span>
{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 ring-1 ring-amber-200">
Gestión
</span>
{/if}
</td> </td>
<!-- Fecha -->
<td class="px-6 py-4 text-gray-500 hidden sm:table-cell"> <td class="px-6 py-4 text-gray-500 hidden sm:table-cell">
{user.created_at ? new Date(user.created_at).toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'} {user.created_at
? new Date(user.created_at).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
: '—'}
</td> </td>
<!-- Estado -->
<td class="px-6 py-4"> <td class="px-6 py-4">
{#if user.is_active} {#if user.is_active}
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-700"> <span
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> class="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-700"
Activo >
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500" />Activo
</span> </span>
{:else} {:else}
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-gray-400"> <span class="inline-flex items-center gap-1.5 text-xs font-medium text-gray-400">
<span class="w-1.5 h-1.5 rounded-full bg-gray-300"></span> <span class="w-1.5 h-1.5 rounded-full bg-gray-300" />Inactivo
Inactivo
</span> </span>
{/if} {/if}
</td> </td>
<!-- Acciones -->
{#if canManageUsers} {#if canManageUsers}
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div
class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
<!-- Editar --> >
<button <button
type="button" type="button"
on:click={() => openEdit(user)} on:click={() => openEdit(user)}
@@ -370,11 +457,14 @@
title="Editar usuario" title="Editar usuario"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg> </svg>
</button> </button>
<!-- Activar/Desactivar -->
<button <button
type="button" type="button"
on:click={() => toggleActive(user)} on:click={() => toggleActive(user)}
@@ -386,16 +476,24 @@
> >
{#if user.is_active} {#if user.is_active}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg> </svg>
{:else} {:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
{/if} {/if}
</button> </button>
<!-- Eliminar (solo CLIENT_ADMIN) -->
{#if isClientAdmin && user.id !== $auth.user?.id} {#if isClientAdmin && user.id !== $auth.user?.id}
<button <button
type="button" type="button"
@@ -404,11 +502,15 @@
title="Eliminar usuario" title="Eliminar usuario"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg> </svg>
</button> </button>
{/if} {/if}
</div> </div>
</td> </td>
{/if} {/if}
@@ -423,59 +525,86 @@
<!-- Modal crear/editar --> <!-- Modal crear/editar -->
{#if showModal} {#if showModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={closeModal}></div> <div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={closeModal} />
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 z-10"> <div
class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 z-10 max-h-[90vh] overflow-y-auto"
>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-gray-900"> <h2 class="text-lg font-semibold text-gray-900">
{modalMode === 'create' ? 'Nuevo usuario' : 'Editar usuario'} {modalMode === 'create' ? 'Nuevo usuario' : 'Editar usuario'}
</h2> </h2>
<button type="button" on:click={closeModal} class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors"> <button
type="button"
on:click={closeModal}
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
<form on:submit|preventDefault={saveUser} class="space-y-4"> <form on:submit|preventDefault={saveUser} class="space-y-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label> <label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
<input type="text" bind:value={form.first_name} required <input
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> type="text"
bind:value={form.first_name}
required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Apellido</label> <label class="block text-sm font-medium text-gray-700 mb-1">Apellido</label>
<input type="text" bind:value={form.last_name} <input
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> type="text"
bind:value={form.last_name}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label> <label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" bind:value={form.email} required <input
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> type="email"
bind:value={form.email}
required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1"> <label class="block text-sm font-medium text-gray-700 mb-1">
{modalMode === 'create' ? 'Contraseña' : 'Nueva contraseña (dejar vacío para no cambiar)'} {modalMode === 'create'
? 'Contraseña'
: 'Nueva contraseña (dejar vacío para no cambiar)'}
</label> </label>
<input type="password" bind:value={form.password} required={modalMode === 'create'} <input
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> type="password"
bind:value={form.password}
required={modalMode === 'create'}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Rol</label> <label class="block text-sm font-medium text-gray-700 mb-1">Rol</label>
<select bind:value={form.role} <select
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"> bind:value={form.role}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value="CLIENT_USER">Usuario</option> <option value="CLIENT_USER">Usuario</option>
<option value="CLIENT_ADMIN">Administrador</option> <option value="CLIENT_ADMIN">Administrador</option>
</select> </select>
</div> </div>
<!-- Permiso de gestión (solo si el rol es CLIENT_USER) -->
{#if form.role === 'CLIENT_USER'} {#if form.role === 'CLIENT_USER'}
<div class="flex items-start gap-3 p-3 bg-amber-50 rounded-lg border border-amber-100"> <div class="flex items-start gap-3 p-3 bg-amber-50 rounded-lg border border-amber-100">
<input <input
@@ -486,7 +615,9 @@
/> />
<label for="can_manage_users" class="text-sm text-gray-700 cursor-pointer"> <label for="can_manage_users" class="text-sm text-gray-700 cursor-pointer">
<span class="font-medium">Permitir gestión de usuarios</span> <span class="font-medium">Permitir gestión de usuarios</span>
<p class="text-xs text-gray-500 mt-0.5">Podrá crear y editar usuarios, pero no eliminarlos</p> <p class="text-xs text-gray-500 mt-0.5">
Podrá crear y editar usuarios, pero no eliminarlos
</p>
</label> </label>
</div> </div>
{/if} {/if}
@@ -499,50 +630,109 @@
bind:checked={form.is_active} bind:checked={form.is_active}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/> />
<label for="is_active" class="text-sm font-medium text-gray-700 cursor-pointer">Usuario activo</label> <label for="is_active" class="text-sm font-medium text-gray-700 cursor-pointer"
>Usuario activo</label
>
</div> </div>
{/if} {/if}
<div class="flex justify-end gap-3 pt-2"> <!-- Sección de vistas -->
<button type="button" on:click={closeModal} <div class="pt-4 border-t border-gray-200">
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"> <div class="flex items-center justify-between mb-3">
Cancelar <h4 class="text-sm font-semibold text-gray-900">Vistas disponibles</h4>
</button> {#if isLoadingPerms}
<button type="submit" disabled={isSaving} <span class="text-xs text-gray-400">Cargando...</span>
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60 transition-colors"> {/if}
{isSaving ? 'Guardando...' : modalMode === 'create' ? 'Crear usuario' : 'Guardar cambios'} </div>
</button> <div class="space-y-2">
{#each CLIENT_ROUTES as route}
<label
class="flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer hover:bg-gray-50 transition-colors
{navPermissions[route.key]
? 'border-blue-200 bg-blue-50'
: 'border-gray-100 bg-gray-50'}"
>
<input
type="checkbox"
bind:checked={navPermissions[route.key]}
disabled={isLoadingPerms}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-gray-700">{route.label}</span>
</label>
{/each}
</div>
<p class="text-xs text-gray-400 mt-2">
Las vistas desactivadas no aparecerán en el menú del usuario.
</p>
</div> </div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
on:click={closeModal}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={isSaving}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60 transition-colors"
>
{isSaving
? 'Guardando...'
: modalMode === 'create'
? 'Crear usuario'
: 'Guardar cambios'}
</button>
</div>
</form> </form>
</div> </div>
</div> </div>
{/if} {/if}
<!-- Confirmacióclsn eliminar --> <!-- Confirmar eliminar -->
{#if showDeleteConfirm && selectedUser} {#if showDeleteConfirm && selectedUser}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => showDeleteConfirm = false}></div> <div
class="absolute inset-0 bg-black/40 backdrop-blur-sm"
on:click={() => (showDeleteConfirm = false)}
/>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6 z-10"> <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6 z-10">
<div class="w-12 h-12 rounded-full bg-rose-100 flex items-center justify-center mx-auto mb-4"> <div class="w-12 h-12 rounded-full bg-rose-100 flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-rose-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-rose-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg> </svg>
</div> </div>
<h3 class="text-center font-semibold text-gray-900 mb-1">Eliminar usuario</h3> <h3 class="text-center font-semibold text-gray-900 mb-1">Eliminar usuario</h3>
<p class="text-center text-sm text-gray-500 mb-6"> <p class="text-center text-sm text-gray-500 mb-6">
¿Seguro que quieres eliminar a <strong>{selectedUser.first_name} {selectedUser.last_name}</strong>? Esta acción no se puede deshacer. ¿Seguro que quieres eliminar a <strong
>{selectedUser.first_name} {selectedUser.last_name}</strong
>? Esta acción no se puede deshacer.
</p> </p>
<div class="flex gap-3"> <div class="flex gap-3">
<button type="button" on:click={() => showDeleteConfirm = false} <button
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"> type="button"
on:click={() => (showDeleteConfirm = false)}
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar Cancelar
</button> </button>
<button type="button" on:click={deleteUser} disabled={isDeleting} <button
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-rose-600 rounded-lg hover:bg-rose-700 disabled:opacity-60 transition-colors"> type="button"
on:click={deleteUser}
disabled={isDeleting}
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-rose-600 rounded-lg hover:bg-rose-700 disabled:opacity-60 transition-colors"
>
{isDeleting ? 'Eliminando...' : 'Eliminar'} {isDeleting ? 'Eliminando...' : 'Eliminar'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}

View File

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

View File

@@ -1,88 +1,60 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { INTERNAL_ROUTES } from '$lib/routes-registry';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { navPerms } from '$lib/stores/navPermissions';
import { onMount } from 'svelte';
export let open = false; export let open = false;
// Navigation items based on user role const NAV_ICONS: Record<string, string> = {
$: navigation = getNavigationForRole($auth.user?.role); '/': 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z',
'/tickets':
'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
'/users':
'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z',
'/tenants':
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
'/categories':
'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
'/systems':
'M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zm14 0a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L19 17.586V12z',
'/sla': 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
'/audit':
'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
'/test-report':
'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
};
function getNavigationForRole(role: string | undefined) { onMount(() => {
const baseNavigation = [ navPerms.load();
});
$: navigation = (() => {
const role = $auth.user?.role;
const isAdmin = role === 'ADMIN';
const items: { name: string; href: string; icon: string }[] = [
{ {
name: 'Dashboard', name: 'Dashboard',
href: '/', href: '/',
icon: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z' icon: NAV_ICONS['/']
},
{
name: 'Tickets',
href: '/tickets',
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2'
} }
]; ];
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') { INTERNAL_ROUTES.forEach(route => {
baseNavigation.push( const hasPermission = isAdmin || $navPerms[route.key] === true;
{ if (hasPermission) {
name: 'Clientes', items.push({
href: '/tenants', name: route.label,
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' href: route.path,
}, icon: NAV_ICONS[route.path] || NAV_ICONS['/tickets']
{ });
name: 'Usuarios', }
href: '/users', });
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z'
},
{
name: 'Categorías',
href: '/categories',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10'
},
{
name: 'Sistemas',
href: '/systems',
icon: 'M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zm14 0a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L19 17.586V12z'
}
);
}
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') { return items;
baseNavigation.push( })();
{
name: 'SLA Management',
href: '/sla',
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'
},
{
name: 'Reportes',
href: '/reports',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
}
);
}
if (role === 'ADMIN') {
baseNavigation.push(
{
name: 'Auditoría',
href: '/audit',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z'
},
{
name: 'Seguridad',
href: '/audit/security',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
},
{
name: 'Reporte Endpoints',
href: '/test-report',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18'
}
);
}
return baseNavigation;
}
function isCurrentPage(href: string) { function isCurrentPage(href: string) {
return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/'); return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
@@ -132,7 +104,6 @@
<p class="text-xs text-primary-100">Panel Interno</p> <p class="text-xs text-primary-100">Panel Interno</p>
</div> </div>
</div> </div>
<button <button
on:click={() => (open = false)} on:click={() => (open = false)}
class="p-2 rounded-md text-primary-100 hover:text-white hover:bg-primary-500 lg:hidden" class="p-2 rounded-md text-primary-100 hover:text-white hover:bg-primary-500 lg:hidden"

View File

@@ -0,0 +1,18 @@
export interface RouteDefinition {
key: string;
label: string;
path: string;
icon?: string;
adminOnly?: boolean;
}
export const INTERNAL_ROUTES: RouteDefinition[] = [
{ key: 'nav:tickets', label: 'Tickets', path: '/tickets' },
{ key: 'nav:users', label: 'Usuarios', path: '/users' },
{ key: 'nav:tenants', label: 'Clientes', path: '/tenants' },
{ key: 'nav:categories', label: 'Categorías', path: '/categories' },
{ key: 'nav:systems', label: 'Sistemas', path: '/systems' },
{ key: 'nav:sla', label: 'SLA', path: '/sla' },
{ key: 'nav:audit', label: 'Auditoría', path: '/audit' },
{ key: 'nav:reports', label: 'Reportes', path: '/test-report' },
]

View File

@@ -0,0 +1,21 @@
import { writable } from 'svelte/store';
import { api } from '$lib/utils/api';
function createNavPermissionsStore() {
const { subscribe, set } = writable<Record<string, boolean>>({});
return {
subscribe,
load: async () => {
try {
const perms = await api.get<Record<string, boolean>>('/permissions/my');
set(perms);
} catch (e) {
set({});
}
},
clear: () => set({})
};
}
export const navPerms = createNavPermissionsStore();

View File

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

View File

@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/utils/api'; import { api } from '$lib/utils/api';
import { auth } from '$lib/stores/auth';
import { toast } from '$lib/stores/toast'; import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import { INTERNAL_ROUTES } from '$lib/routes-registry';
let users = []; let users = [];
let tenants = []; let tenants = [];
let isLoading = false; let isLoading = false;
let showModal = false; let showModal = false;
let editingUser = null; let editingUser = null;
let navPermissions: Record<string, boolean> = {};
let isLoadingPerms = false;
let formData = { let formData = {
email: '', email: '',
@@ -29,6 +33,12 @@
{ value: 'CLIENT_USER', label: 'Usuario Cliente' } { value: 'CLIENT_USER', label: 'Usuario Cliente' }
]; ];
// Solo ADMIN y SUPPORT_MANAGER pueden gestionar permisos de navegación
$: canManageNav = $auth.user?.role === 'ADMIN' || $auth.user?.role === 'SUPPORT_MANAGER';
// Rutas relevantes según el rol del usuario que se está editando
$: relevantRoutes = INTERNAL_ROUTES;
async function loadData() { async function loadData() {
isLoading = true; isLoading = true;
try { try {
@@ -45,6 +55,30 @@
} }
} }
function initNavPermissions() {
// Inicializar todos en false por defecto
navPermissions = {};
INTERNAL_ROUTES.forEach(r => {
navPermissions[r.key] = false;
});
}
async function loadUserNavPermissions(userId: string) {
isLoadingPerms = true;
try {
const perms = await api.get(`/permissions/user/${userId}`);
// perms es un objeto { permission: granted }
INTERNAL_ROUTES.forEach(r => {
navPermissions[r.key] = perms[r.key] ?? false;
});
} catch (e) {
// Si falla, dejar defaults
initNavPermissions();
} finally {
isLoadingPerms = false;
}
}
function openCreateModal() { function openCreateModal() {
editingUser = null; editingUser = null;
formData = { formData = {
@@ -56,40 +90,61 @@
tenant_id: '', tenant_id: '',
is_active: true is_active: true
}; };
initNavPermissions();
showModal = true; showModal = true;
} }
function openEditModal(user) { async function openEditModal(user) {
editingUser = user; editingUser = user;
formData = { formData = {
email: user.email, email: user.email,
password: '', // Don't show password password: '',
first_name: user.first_name, first_name: user.first_name,
last_name: user.last_name, last_name: user.last_name,
role: user.role, role: user.role,
tenant_id: user.tenant_id || '', tenant_id: user.tenant_id || '',
is_active: user.is_active is_active: user.is_active
}; };
initNavPermissions();
showModal = true; showModal = true;
if (canManageNav) {
await loadUserNavPermissions(user.id);
}
} }
async function handleSubmit() { async function handleSubmit() {
try { try {
const payload = { ...formData }; const payload = { ...formData };
if (!payload.password) delete payload.password; // Don't send empty password on edit if (!payload.password) delete payload.password;
if (!payload.tenant_id) payload.tenant_id = null; // Send null if empty string if (!payload.tenant_id) payload.tenant_id = null;
let userId: string;
if (editingUser) { if (editingUser) {
await api.put(`/users/${editingUser.id}`, payload); await api.put(`/users/${editingUser.id}`, payload);
userId = editingUser.id;
toast.success('Usuario actualizado'); toast.success('Usuario actualizado');
} else { } else {
if (!payload.password) { if (!payload.password) {
toast.error('La contraseña es requerida para nuevos usuarios'); toast.error('La contraseña es requerida para nuevos usuarios');
return; return;
} }
await api.post('/users/', payload); const newUser = await api.post('/users/', payload);
userId = newUser.id;
toast.success('Usuario creado'); toast.success('Usuario creado');
} }
// Guardar permisos de navegación
if (canManageNav && userId) {
try {
await api.put(`/permissions/user/${userId}`, {
permissions: navPermissions
});
} catch (e) {
toast.error('Usuario guardado pero error al guardar permisos de navegación');
}
}
showModal = false; showModal = false;
loadData(); loadData();
} catch (e) { } catch (e) {
@@ -130,20 +185,18 @@
<table class="min-w-full divide-y divide-gray-300"> <table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Usuario</th> <th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Usuario</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Rol</th> <th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Rol</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente (Tenant)</th> <th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente (Tenant)</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th> <th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6"> <th class="relative py-3.5 pl-3 pr-4 sm:pr-6"><span class="sr-only">Acciones</span></th>
<span class="sr-only">Acciones</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading} {#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr> <tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
{:else if users.length === 0} {:else if users.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay usuarios registrados</td></tr> <tr><td colspan="5" class="text-center py-4">No hay usuarios registrados</td></tr>
{:else} {:else}
{#each users as user} {#each users as user}
<tr> <tr>
@@ -177,27 +230,34 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label for="first_name" class="block text-sm font-medium text-gray-700">Nombre</label> <label for="first_name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"> <input type="text" id="first_name" bind:value={formData.first_name} required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div> </div>
<div> <div>
<label for="last_name" class="block text-sm font-medium text-gray-700">Apellido</label> <label for="last_name" class="block text-sm font-medium text-gray-700">Apellido</label>
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"> <input type="text" id="last_name" bind:value={formData.last_name} required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div> </div>
</div> </div>
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label> <label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"> <input type="email" id="email" bind:value={formData.email} required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700">Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}</label> <label for="password" class="block text-sm font-medium text-gray-700">
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"> Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}
</label>
<input type="password" id="password" bind:value={formData.password}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div> </div>
<div> <div>
<label for="role" class="block text-sm font-medium text-gray-700">Rol</label> <label for="role" class="block text-sm font-medium text-gray-700">Rol</label>
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"> <select id="role" bind:value={formData.role}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
{#each ROLES as role} {#each ROLES as role}
<option value={role.value}>{role.label}</option> <option value={role.value}>{role.label}</option>
{/each} {/each}
@@ -205,8 +265,9 @@
</div> </div>
<div> <div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - solo para usuarios externos)</label> <label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional)</label>
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"> <select id="tenant" bind:value={formData.tenant_id}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<option value="">-- Ninguno (Usuario Interno) --</option> <option value="">-- Ninguno (Usuario Interno) --</option>
{#each tenants as tenant} {#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option> <option value={tenant.id}>{tenant.name}</option>
@@ -215,17 +276,49 @@
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500"> <input type="checkbox" id="is_active" bind:checked={formData.is_active}
class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label> <label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div> </div>
<!-- Sección de vistas — solo para admins -->
{#if canManageNav}
<div class="pt-4 border-t border-gray-200">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-900">Vistas disponibles</h4>
{#if isLoadingPerms}
<span class="text-xs text-gray-400">Cargando permisos...</span>
{/if}
</div>
<div class="grid grid-cols-2 gap-2">
{#each INTERNAL_ROUTES as route}
<label class="flex items-center gap-2 p-2 rounded-md border cursor-pointer hover:bg-gray-50
{navPermissions[route.key] ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
<input
type="checkbox"
bind:checked={navPermissions[route.key]}
disabled={isLoadingPerms}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-gray-700">{route.label}</span>
</label>
{/each}
</div>
<p class="text-xs text-gray-400 mt-2">
Las vistas desactivadas no aparecerán en el menú del usuario.
</p>
</div>
{/if}
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense"> <div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm"> <button type="submit"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 sm:col-start-2 sm:text-sm">
Guardar Guardar
</button> </button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm"> <button type="button" on:click={() => showModal = false}
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar Cancelar
</button> </button>
</div> </div>
</form> </form>
</Modal> </Modal>