Compare commits
3 Commits
daf3a524c7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 94ca24e00e | |||
| 7b00f1336d | |||
| 08816ab73a |
@@ -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] = []
|
||||||
|
|||||||
130
backend/app/api/v1/endpoints/permissions.py
Normal file
130
backend/app/api/v1/endpoints/permissions.py
Normal 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"}
|
||||||
@@ -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},
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
)
|
)
|
||||||
@@ -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})>"
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
41
backend/migrations/versions/9158111a7e00_add_issue_status.py
Normal file
41
backend/migrations/versions/9158111a7e00_add_issue_status.py
Normal 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")
|
||||||
@@ -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}
|
||||||
|
|||||||
221
frontend-client/src/lib/components/IssueDetailModal.svelte
Normal file
221
frontend-client/src/lib/components/IssueDetailModal.svelte
Normal 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>
|
||||||
11
frontend-client/src/lib/routes-registry.ts
Normal file
11
frontend-client/src/lib/routes-registry.ts
Normal 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' },
|
||||||
|
]
|
||||||
@@ -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'
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
36
frontend-client/src/lib/stores/navPermissions.ts
Normal file
36
frontend-client/src/lib/stores/navPermissions.ts
Normal 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();
|
||||||
@@ -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 })
|
||||||
};
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
227
frontend-internal/src/lib/components/IssueDetailModal.svelte
Normal file
227
frontend-internal/src/lib/components/IssueDetailModal.svelte
Normal 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>
|
||||||
@@ -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"
|
||||||
|
|||||||
18
frontend-internal/src/lib/routes-registry.ts
Normal file
18
frontend-internal/src/lib/routes-registry.ts
Normal 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' },
|
||||||
|
]
|
||||||
21
frontend-internal/src/lib/stores/navPermissions.ts
Normal file
21
frontend-internal/src/lib/stores/navPermissions.ts
Normal 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();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user