Files
WhatsAppCentralizado/docs/plans/2026-01-29-fase-3-inbox-multiagente.md
Claude AI 2ed272e511 docs: add Fase 3 Inbox Avanzado + Multi-agente implementation plan
14 tasks covering:
- Queue and QueueAgent database models
- QuickReply model for fast responses
- AssignmentService with round-robin, least-busy, skill-based
- Conversation transfers (queue, agent, bot)
- Internal notes system
- Agent status management
- Supervisor dashboard API
- Frontend: Queues page, enhanced Inbox, Supervisor Dashboard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:49:49 +00:00

71 KiB

Fase 3: Inbox Avanzado + Multi-agente - Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implementar sistema completo de colas, asignación inteligente de conversaciones, transferencia bot↔humano, estados de agente, respuestas rápidas, notas internas y panel de supervisor.

Architecture: Backend con modelos Queue, QueueAgent, QuickReply y campos adicionales en Conversation para SLA tracking. API Gateway expone endpoints para gestión de colas, asignación y transferencias. Frontend añade panel de supervisor, filtros avanzados en Inbox, y componentes para respuestas rápidas y notas internas.

Tech Stack: Python/FastAPI (backend), PostgreSQL (DB), React/TypeScript/Ant Design (frontend)


Task 1: Database Models para Colas y Agentes

Files:

  • Create: services/api-gateway/app/models/queue.py
  • Modify: services/api-gateway/app/models/__init__.py

Code:

# services/api-gateway/app/models/queue.py
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Integer, ForeignKey, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from sqlalchemy.orm import relationship
import enum
from app.core.database import Base


class AssignmentMethod(str, enum.Enum):
    ROUND_ROBIN = "round_robin"
    LEAST_BUSY = "least_busy"
    SKILL_BASED = "skill_based"


class Queue(Base):
    __tablename__ = "queues"

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    name = Column(String(100), nullable=False)
    description = Column(String(500), nullable=True)
    assignment_method = Column(SQLEnum(AssignmentMethod), default=AssignmentMethod.ROUND_ROBIN, nullable=False)
    max_per_agent = Column(Integer, default=10, nullable=False)
    sla_first_response = Column(Integer, default=300, nullable=False)  # seconds
    sla_resolution = Column(Integer, default=86400, nullable=False)  # seconds
    business_hours = Column(JSONB, default=dict)  # {"mon": {"start": "09:00", "end": "18:00"}, ...}
    fallback_flow_id = Column(UUID(as_uuid=True), ForeignKey("flows.id"), nullable=True)
    is_active = Column(Boolean, default=True, nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

    agents = relationship("QueueAgent", back_populates="queue")
    conversations = relationship("Conversation", back_populates="queue")


class QueueAgent(Base):
    __tablename__ = "queue_agents"

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"), nullable=False)
    user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
    is_supervisor = Column(Boolean, default=False, nullable=False)
    skills = Column(ARRAY(String), default=list)
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

    queue = relationship("Queue", back_populates="agents")
    user = relationship("User")

Modify services/api-gateway/app/models/__init__.py:

from app.models.user import User, UserRole, UserStatus
from app.models.whatsapp import (
    WhatsAppAccount, Contact, Conversation, Message,
    AccountStatus, ConversationStatus, MessageDirection, MessageType, MessageStatus
)
from app.models.flow import Flow, FlowSession, TriggerType
from app.models.queue import Queue, QueueAgent, AssignmentMethod

Commit: feat(fase3): add Queue and QueueAgent database models


Task 2: Extender Conversation para SLA y Queue

Files:

  • Modify: services/api-gateway/app/models/whatsapp.py

Code to add after existing Conversation columns (before relationships):

# Add to Conversation class after existing columns:
    queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"), nullable=True)
    priority = Column(String(20), default="normal", nullable=False)  # low, normal, high, urgent
    sla_first_response_at = Column(DateTime, nullable=True)
    sla_first_response_met = Column(Boolean, nullable=True)
    resolved_at = Column(DateTime, nullable=True)
    csat_score = Column(Integer, nullable=True)  # 1-5
    csat_feedback = Column(String(500), nullable=True)

# Add relationship:
    queue = relationship("Queue", back_populates="conversations")

Import at top:

from sqlalchemy.orm import relationship

Commit: feat(fase3): extend Conversation model with SLA and queue fields


Task 3: Quick Replies Model

Files:

  • Create: services/api-gateway/app/models/quick_reply.py
  • Modify: services/api-gateway/app/models/__init__.py

Code:

# services/api-gateway/app/models/quick_reply.py
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Text, ForeignKey, DateTime
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.core.database import Base


class QuickReply(Base):
    __tablename__ = "quick_replies"

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    shortcut = Column(String(50), nullable=False, index=True)  # e.g., "/saludo"
    content = Column(Text, nullable=False)
    attachments = Column(JSONB, default=list)  # [{type: "image", url: "..."}]
    queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"), nullable=True)  # null = global
    created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

    queue = relationship("Queue")
    creator = relationship("User")

Update __init__.py:

from app.models.quick_reply import QuickReply

Commit: feat(fase3): add QuickReply database model


Task 4: Queue API Schemas

Files:

  • Create: services/api-gateway/app/schemas/queue.py

Code:

# services/api-gateway/app/schemas/queue.py
from pydantic import BaseModel
from typing import Optional, List
from uuid import UUID
from datetime import datetime
from app.models.queue import AssignmentMethod


class QueueCreate(BaseModel):
    name: str
    description: Optional[str] = None
    assignment_method: AssignmentMethod = AssignmentMethod.ROUND_ROBIN
    max_per_agent: int = 10
    sla_first_response: int = 300
    sla_resolution: int = 86400
    business_hours: Optional[dict] = None
    fallback_flow_id: Optional[UUID] = None


class QueueUpdate(BaseModel):
    name: Optional[str] = None
    description: Optional[str] = None
    assignment_method: Optional[AssignmentMethod] = None
    max_per_agent: Optional[int] = None
    sla_first_response: Optional[int] = None
    sla_resolution: Optional[int] = None
    business_hours: Optional[dict] = None
    fallback_flow_id: Optional[UUID] = None
    is_active: Optional[bool] = None


class QueueAgentAdd(BaseModel):
    user_id: UUID
    is_supervisor: bool = False
    skills: List[str] = []


class QueueAgentResponse(BaseModel):
    id: UUID
    user_id: UUID
    user_name: Optional[str] = None
    user_email: Optional[str] = None
    is_supervisor: bool
    skills: List[str]
    created_at: datetime

    class Config:
        from_attributes = True


class QueueResponse(BaseModel):
    id: UUID
    name: str
    description: Optional[str]
    assignment_method: AssignmentMethod
    max_per_agent: int
    sla_first_response: int
    sla_resolution: int
    business_hours: Optional[dict]
    fallback_flow_id: Optional[UUID]
    is_active: bool
    created_at: datetime
    agent_count: Optional[int] = 0

    class Config:
        from_attributes = True


class QueueDetailResponse(QueueResponse):
    agents: List[QueueAgentResponse] = []


class QuickReplyCreate(BaseModel):
    shortcut: str
    content: str
    attachments: List[dict] = []
    queue_id: Optional[UUID] = None


class QuickReplyUpdate(BaseModel):
    shortcut: Optional[str] = None
    content: Optional[str] = None
    attachments: Optional[List[dict]] = None
    queue_id: Optional[UUID] = None


class QuickReplyResponse(BaseModel):
    id: UUID
    shortcut: str
    content: str
    attachments: List[dict]
    queue_id: Optional[UUID]
    created_by: UUID
    created_at: datetime

    class Config:
        from_attributes = True

Commit: feat(fase3): add Queue and QuickReply API schemas


Task 5: Queue API Routes

Files:

  • Create: services/api-gateway/app/routers/queues.py
  • Modify: services/api-gateway/app/main.py

Code:

# services/api-gateway/app/routers/queues.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from uuid import UUID
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, UserRole
from app.models.queue import Queue, QueueAgent
from app.models.quick_reply import QuickReply
from app.schemas.queue import (
    QueueCreate, QueueUpdate, QueueResponse, QueueDetailResponse,
    QueueAgentAdd, QueueAgentResponse,
    QuickReplyCreate, QuickReplyUpdate, QuickReplyResponse
)

router = APIRouter(prefix="/api/queues", tags=["queues"])


def require_admin(current_user: User = Depends(get_current_user)):
    if current_user.role != UserRole.ADMIN:
        raise HTTPException(status_code=403, detail="Admin required")
    return current_user


def require_supervisor(current_user: User = Depends(get_current_user)):
    if current_user.role not in [UserRole.ADMIN, UserRole.SUPERVISOR]:
        raise HTTPException(status_code=403, detail="Supervisor or Admin required")
    return current_user


# Queue CRUD
@router.post("", response_model=QueueResponse)
def create_queue(
    request: QueueCreate,
    db: Session = Depends(get_db),
    current_user: User = Depends(require_admin),
):
    queue = Queue(**request.model_dump())
    db.add(queue)
    db.commit()
    db.refresh(queue)
    return queue


@router.get("", response_model=List[QueueResponse])
def list_queues(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    queues = db.query(Queue).filter(Queue.is_active == True).all()
    result = []
    for q in queues:
        q_dict = QueueResponse.model_validate(q).model_dump()
        q_dict["agent_count"] = len(q.agents)
        result.append(QueueResponse(**q_dict))
    return result


@router.get("/{queue_id}", response_model=QueueDetailResponse)
def get_queue(
    queue_id: UUID,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    queue = db.query(Queue).filter(Queue.id == queue_id).first()
    if not queue:
        raise HTTPException(status_code=404, detail="Queue not found")

    agents_data = []
    for qa in queue.agents:
        agents_data.append(QueueAgentResponse(
            id=qa.id,
            user_id=qa.user_id,
            user_name=qa.user.name if qa.user else None,
            user_email=qa.user.email if qa.user else None,
            is_supervisor=qa.is_supervisor,
            skills=qa.skills or [],
            created_at=qa.created_at,
        ))

    return QueueDetailResponse(
        **QueueResponse.model_validate(queue).model_dump(),
        agents=agents_data,
        agent_count=len(agents_data),
    )


@router.put("/{queue_id}", response_model=QueueResponse)
def update_queue(
    queue_id: UUID,
    request: QueueUpdate,
    db: Session = Depends(get_db),
    current_user: User = Depends(require_admin),
):
    queue = db.query(Queue).filter(Queue.id == queue_id).first()
    if not queue:
        raise HTTPException(status_code=404, detail="Queue not found")

    for key, value in request.model_dump(exclude_unset=True).items():
        setattr(queue, key, value)

    db.commit()
    db.refresh(queue)
    return queue


@router.delete("/{queue_id}")
def delete_queue(
    queue_id: UUID,
    db: Session = Depends(get_db),
    current_user: User = Depends(require_admin),
):
    queue = db.query(Queue).filter(Queue.id == queue_id).first()
    if not queue:
        raise HTTPException(status_code=404, detail="Queue not found")

    queue.is_active = False
    db.commit()
    return {"success": True}


# Queue Agents
@router.post("/{queue_id}/agents", response_model=QueueAgentResponse)
def add_agent_to_queue(
    queue_id: UUID,
    request: QueueAgentAdd,
    db: Session = Depends(get_db),
    current_user: User = Depends(require_admin),
):
    queue = db.query(Queue).filter(Queue.id == queue_id).first()
    if not queue:
        raise HTTPException(status_code=404, detail="Queue not found")

    existing = db.query(QueueAgent).filter(
        QueueAgent.queue_id == queue_id,
        QueueAgent.user_id == request.user_id
    ).first()
    if existing:
        raise HTTPException(status_code=400, detail="Agent already in queue")

    qa = QueueAgent(
        queue_id=queue_id,
        user_id=request.user_id,
        is_supervisor=request.is_supervisor,
        skills=request.skills,
    )
    db.add(qa)
    db.commit()
    db.refresh(qa)

    return QueueAgentResponse(
        id=qa.id,
        user_id=qa.user_id,
        user_name=qa.user.name if qa.user else None,
        user_email=qa.user.email if qa.user else None,
        is_supervisor=qa.is_supervisor,
        skills=qa.skills or [],
        created_at=qa.created_at,
    )


@router.delete("/{queue_id}/agents/{user_id}")
def remove_agent_from_queue(
    queue_id: UUID,
    user_id: UUID,
    db: Session = Depends(get_db),
    current_user: User = Depends(require_admin),
):
    qa = db.query(QueueAgent).filter(
        QueueAgent.queue_id == queue_id,
        QueueAgent.user_id == user_id
    ).first()
    if not qa:
        raise HTTPException(status_code=404, detail="Agent not in queue")

    db.delete(qa)
    db.commit()
    return {"success": True}


# Quick Replies
@router.get("/quick-replies", response_model=List[QuickReplyResponse])
def list_quick_replies(
    queue_id: UUID = None,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    query = db.query(QuickReply)
    if queue_id:
        query = query.filter(
            (QuickReply.queue_id == queue_id) | (QuickReply.queue_id == None)
        )
    else:
        query = query.filter(QuickReply.queue_id == None)

    return query.all()


@router.post("/quick-replies", response_model=QuickReplyResponse)
def create_quick_reply(
    request: QuickReplyCreate,
    db: Session = Depends(get_db),
    current_user: User = Depends(require_supervisor),
):
    qr = QuickReply(
        **request.model_dump(),
        created_by=current_user.id,
    )
    db.add(qr)
    db.commit()
    db.refresh(qr)
    return qr


@router.put("/quick-replies/{reply_id}", response_model=QuickReplyResponse)
def update_quick_reply(
    reply_id: UUID,
    request: QuickReplyUpdate,
    db: Session = Depends(get_db),
    current_user: User = Depends(require_supervisor),
):
    qr = db.query(QuickReply).filter(QuickReply.id == reply_id).first()
    if not qr:
        raise HTTPException(status_code=404, detail="Quick reply not found")

    for key, value in request.model_dump(exclude_unset=True).items():
        setattr(qr, key, value)

    db.commit()
    db.refresh(qr)
    return qr


@router.delete("/quick-replies/{reply_id}")
def delete_quick_reply(
    reply_id: UUID,
    db: Session = Depends(get_db),
    current_user: User = Depends(require_supervisor),
):
    qr = db.query(QuickReply).filter(QuickReply.id == reply_id).first()
    if not qr:
        raise HTTPException(status_code=404, detail="Quick reply not found")

    db.delete(qr)
    db.commit()
    return {"success": True}

Add to main.py imports and router:

from app.routers import auth, whatsapp, flows, queues

app.include_router(queues.router)

Commit: feat(fase3): add Queue and QuickReply API routes


Task 6: Assignment Service

Files:

  • Create: services/api-gateway/app/services/assignment.py

Code:

# services/api-gateway/app/services/assignment.py
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from uuid import UUID
from datetime import datetime
from app.models.queue import Queue, QueueAgent, AssignmentMethod
from app.models.whatsapp import Conversation, ConversationStatus
from app.models.user import User, UserStatus


class AssignmentService:
    def __init__(self, db: Session):
        self.db = db

    def assign_conversation(self, conversation_id: UUID, queue_id: UUID) -> Optional[User]:
        """Assign a conversation to an agent based on queue settings"""
        queue = self.db.query(Queue).filter(Queue.id == queue_id).first()
        if not queue or not queue.is_active:
            return None

        conversation = self.db.query(Conversation).filter(
            Conversation.id == conversation_id
        ).first()
        if not conversation:
            return None

        # Get available agents (online and not at capacity)
        available_agents = self._get_available_agents(queue)
        if not available_agents:
            return None

        # Select agent based on assignment method
        if queue.assignment_method == AssignmentMethod.ROUND_ROBIN:
            agent = self._round_robin_select(queue, available_agents)
        elif queue.assignment_method == AssignmentMethod.LEAST_BUSY:
            agent = self._least_busy_select(available_agents)
        elif queue.assignment_method == AssignmentMethod.SKILL_BASED:
            agent = self._skill_based_select(conversation, available_agents)
        else:
            agent = available_agents[0] if available_agents else None

        if agent:
            conversation.assigned_to = agent.id
            conversation.queue_id = queue_id
            conversation.status = ConversationStatus.ACTIVE
            self.db.commit()

        return agent

    def _get_available_agents(self, queue: Queue) -> list:
        """Get agents who are online and have capacity"""
        agents = []
        for qa in queue.agents:
            user = qa.user
            if not user or user.status != UserStatus.ONLINE:
                continue

            # Count active conversations
            active_count = self.db.query(Conversation).filter(
                Conversation.assigned_to == user.id,
                Conversation.status.in_([ConversationStatus.ACTIVE, ConversationStatus.WAITING])
            ).count()

            if active_count < queue.max_per_agent:
                agents.append(user)

        return agents

    def _round_robin_select(self, queue: Queue, agents: list) -> Optional[User]:
        """Select agent with round-robin: least recently assigned"""
        if not agents:
            return None

        # Find agent with oldest last assignment
        agent_last_assign = {}
        for agent in agents:
            last_conv = self.db.query(Conversation).filter(
                Conversation.assigned_to == agent.id
            ).order_by(Conversation.created_at.desc()).first()

            agent_last_assign[agent.id] = last_conv.created_at if last_conv else datetime.min

        # Sort by last assignment (oldest first)
        sorted_agents = sorted(agents, key=lambda a: agent_last_assign[a.id])
        return sorted_agents[0] if sorted_agents else None

    def _least_busy_select(self, agents: list) -> Optional[User]:
        """Select agent with fewest active conversations"""
        if not agents:
            return None

        min_count = float('inf')
        selected = None

        for agent in agents:
            count = self.db.query(Conversation).filter(
                Conversation.assigned_to == agent.id,
                Conversation.status.in_([ConversationStatus.ACTIVE, ConversationStatus.WAITING])
            ).count()

            if count < min_count:
                min_count = count
                selected = agent

        return selected

    def _skill_based_select(self, conversation: Conversation, agents: list) -> Optional[User]:
        """Select agent based on skills (for now, just return least busy)"""
        # TODO: Implement skill matching based on conversation tags/metadata
        return self._least_busy_select(agents)

    def transfer_to_queue(
        self,
        conversation_id: UUID,
        target_queue_id: UUID,
        keep_history: bool = True
    ) -> bool:
        """Transfer conversation to another queue"""
        conversation = self.db.query(Conversation).filter(
            Conversation.id == conversation_id
        ).first()
        if not conversation:
            return False

        conversation.queue_id = target_queue_id
        conversation.assigned_to = None
        conversation.status = ConversationStatus.WAITING
        self.db.commit()

        # Try to assign immediately
        self.assign_conversation(conversation_id, target_queue_id)
        return True

    def transfer_to_agent(
        self,
        conversation_id: UUID,
        agent_id: UUID
    ) -> bool:
        """Transfer conversation directly to a specific agent"""
        conversation = self.db.query(Conversation).filter(
            Conversation.id == conversation_id
        ).first()
        if not conversation:
            return False

        agent = self.db.query(User).filter(User.id == agent_id).first()
        if not agent:
            return False

        conversation.assigned_to = agent_id
        conversation.status = ConversationStatus.ACTIVE
        self.db.commit()
        return True

    def transfer_to_bot(self, conversation_id: UUID) -> bool:
        """Transfer conversation back to bot"""
        conversation = self.db.query(Conversation).filter(
            Conversation.id == conversation_id
        ).first()
        if not conversation:
            return False

        conversation.assigned_to = None
        conversation.status = ConversationStatus.BOT
        self.db.commit()
        return True

    def resolve_conversation(
        self,
        conversation_id: UUID,
        csat_score: int = None,
        csat_feedback: str = None
    ) -> bool:
        """Mark conversation as resolved"""
        conversation = self.db.query(Conversation).filter(
            Conversation.id == conversation_id
        ).first()
        if not conversation:
            return False

        conversation.status = ConversationStatus.RESOLVED
        conversation.resolved_at = datetime.utcnow()
        if csat_score:
            conversation.csat_score = csat_score
        if csat_feedback:
            conversation.csat_feedback = csat_feedback

        self.db.commit()
        return True

Commit: feat(fase3): add AssignmentService for queue management


Task 7: Conversation Actions API

Files:

  • Modify: services/api-gateway/app/routers/whatsapp.py
  • Modify: services/api-gateway/app/schemas/whatsapp.py

Add to schemas/whatsapp.py:

# Add these new schemas

class TransferToQueueRequest(BaseModel):
    queue_id: UUID


class TransferToAgentRequest(BaseModel):
    agent_id: UUID


class ResolveConversationRequest(BaseModel):
    csat_score: Optional[int] = None
    csat_feedback: Optional[str] = None


class InternalNoteRequest(BaseModel):
    content: str

Add to routers/whatsapp.py (after existing routes):

from app.services.assignment import AssignmentService
from app.schemas.whatsapp import (
    # ... existing imports ...
    TransferToQueueRequest, TransferToAgentRequest,
    ResolveConversationRequest, InternalNoteRequest
)


@router.post("/conversations/{conversation_id}/transfer-to-queue")
def transfer_to_queue(
    conversation_id: UUID,
    request: TransferToQueueRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    service = AssignmentService(db)
    if service.transfer_to_queue(conversation_id, request.queue_id):
        return {"success": True}
    raise HTTPException(status_code=400, detail="Transfer failed")


@router.post("/conversations/{conversation_id}/transfer-to-agent")
def transfer_to_agent(
    conversation_id: UUID,
    request: TransferToAgentRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    service = AssignmentService(db)
    if service.transfer_to_agent(conversation_id, request.agent_id):
        return {"success": True}
    raise HTTPException(status_code=400, detail="Transfer failed")


@router.post("/conversations/{conversation_id}/transfer-to-bot")
def transfer_to_bot(
    conversation_id: UUID,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    service = AssignmentService(db)
    if service.transfer_to_bot(conversation_id):
        return {"success": True}
    raise HTTPException(status_code=400, detail="Transfer failed")


@router.post("/conversations/{conversation_id}/resolve")
def resolve_conversation(
    conversation_id: UUID,
    request: ResolveConversationRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    service = AssignmentService(db)
    if service.resolve_conversation(
        conversation_id,
        request.csat_score,
        request.csat_feedback
    ):
        return {"success": True}
    raise HTTPException(status_code=400, detail="Failed to resolve")


@router.post("/conversations/{conversation_id}/notes")
def add_internal_note(
    conversation_id: UUID,
    request: InternalNoteRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    conversation = db.query(Conversation).filter(
        Conversation.id == conversation_id
    ).first()
    if not conversation:
        raise HTTPException(status_code=404, detail="Conversation not found")

    message = Message(
        conversation_id=conversation.id,
        direction=MessageDirection.OUTBOUND,
        type=MessageType.TEXT,
        content=request.content,
        sent_by=current_user.id,
        is_internal_note=True,
        status=MessageStatus.DELIVERED,
    )
    db.add(message)
    db.commit()
    db.refresh(message)
    return {"success": True, "message_id": str(message.id)}

Commit: feat(fase3): add conversation transfer and note API routes


Task 8: Agent Status API

Files:

  • Modify: services/api-gateway/app/routers/auth.py
  • Add to: services/api-gateway/app/schemas/auth.py

Add to schemas/auth.py:

from app.models.user import UserStatus

class UpdateStatusRequest(BaseModel):
    status: UserStatus

Add to routers/auth.py:

from app.schemas.auth import UpdateStatusRequest
from app.models.user import UserStatus


@router.put("/me/status")
def update_my_status(
    request: UpdateStatusRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    current_user.status = request.status
    db.commit()
    return {"success": True, "status": request.status.value}


@router.get("/agents")
def list_agents(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """List all agents with their status and conversation counts"""
    from app.models.whatsapp import Conversation, ConversationStatus

    agents = db.query(User).filter(User.is_active == True).all()
    result = []

    for agent in agents:
        active_count = db.query(Conversation).filter(
            Conversation.assigned_to == agent.id,
            Conversation.status.in_([ConversationStatus.ACTIVE, ConversationStatus.WAITING])
        ).count()

        result.append({
            "id": str(agent.id),
            "name": agent.name,
            "email": agent.email,
            "role": agent.role.value,
            "status": agent.status.value,
            "active_conversations": active_count,
        })

    return result

Commit: feat(fase3): add agent status update and list endpoints


Task 9: Supervisor Dashboard API

Files:

  • Create: services/api-gateway/app/routers/supervisor.py
  • Modify: services/api-gateway/app/main.py

Code:

# services/api-gateway/app/routers/supervisor.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List
from datetime import datetime, timedelta
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, UserRole, UserStatus
from app.models.whatsapp import Conversation, Message, ConversationStatus
from app.models.queue import Queue

router = APIRouter(prefix="/api/supervisor", tags=["supervisor"])


def require_supervisor(current_user: User = Depends(get_current_user)):
    if current_user.role not in [UserRole.ADMIN, UserRole.SUPERVISOR]:
        raise HTTPException(status_code=403, detail="Supervisor or Admin required")
    return current_user


@router.get("/dashboard")
def get_dashboard(
    db: Session = Depends(get_db),
    current_user: User = Depends(require_supervisor),
):
    """Get supervisor dashboard data"""
    now = datetime.utcnow()
    today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)

    # Conversations by status
    status_counts = {}
    for status in ConversationStatus:
        count = db.query(Conversation).filter(
            Conversation.status == status
        ).count()
        status_counts[status.value] = count

    # Waiting conversations (queue)
    waiting_conversations = db.query(Conversation).filter(
        Conversation.status == ConversationStatus.WAITING
    ).count()

    # Active agents
    active_agents = db.query(User).filter(
        User.status == UserStatus.ONLINE,
        User.is_active == True
    ).count()

    # Total agents
    total_agents = db.query(User).filter(
        User.role.in_([UserRole.AGENT, UserRole.SUPERVISOR]),
        User.is_active == True
    ).count()

    # Today's stats
    resolved_today = db.query(Conversation).filter(
        Conversation.resolved_at >= today_start
    ).count()

    messages_today = db.query(Message).filter(
        Message.created_at >= today_start
    ).count()

    # Average response time (simplified)
    avg_response = None  # TODO: Calculate from SLA data

    # CSAT average
    csat_result = db.query(func.avg(Conversation.csat_score)).filter(
        Conversation.csat_score != None,
        Conversation.resolved_at >= today_start
    ).scalar()
    avg_csat = round(float(csat_result), 2) if csat_result else None

    return {
        "conversations": {
            "by_status": status_counts,
            "waiting": waiting_conversations,
            "resolved_today": resolved_today,
        },
        "agents": {
            "online": active_agents,
            "total": total_agents,
        },
        "messages_today": messages_today,
        "avg_csat": avg_csat,
    }


@router.get("/agents")
def get_agents_status(
    db: Session = Depends(get_db),
    current_user: User = Depends(require_supervisor),
):
    """Get detailed agent status for supervisor view"""
    agents = db.query(User).filter(
        User.role.in_([UserRole.AGENT, UserRole.SUPERVISOR]),
        User.is_active == True
    ).all()

    result = []
    for agent in agents:
        # Active conversations
        active = db.query(Conversation).filter(
            Conversation.assigned_to == agent.id,
            Conversation.status == ConversationStatus.ACTIVE
        ).count()

        # Waiting conversations
        waiting = db.query(Conversation).filter(
            Conversation.assigned_to == agent.id,
            Conversation.status == ConversationStatus.WAITING
        ).count()

        # Resolved today
        today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
        resolved_today = db.query(Conversation).filter(
            Conversation.assigned_to == agent.id,
            Conversation.resolved_at >= today_start
        ).count()

        result.append({
            "id": str(agent.id),
            "name": agent.name,
            "email": agent.email,
            "role": agent.role.value,
            "status": agent.status.value,
            "active_conversations": active,
            "waiting_conversations": waiting,
            "resolved_today": resolved_today,
        })

    return result


@router.get("/queues")
def get_queues_status(
    db: Session = Depends(get_db),
    current_user: User = Depends(require_supervisor),
):
    """Get queue status for supervisor view"""
    queues = db.query(Queue).filter(Queue.is_active == True).all()

    result = []
    for queue in queues:
        waiting = db.query(Conversation).filter(
            Conversation.queue_id == queue.id,
            Conversation.status == ConversationStatus.WAITING
        ).count()

        active = db.query(Conversation).filter(
            Conversation.queue_id == queue.id,
            Conversation.status == ConversationStatus.ACTIVE
        ).count()

        online_agents = sum(
            1 for qa in queue.agents
            if qa.user and qa.user.status == UserStatus.ONLINE
        )

        result.append({
            "id": str(queue.id),
            "name": queue.name,
            "waiting_conversations": waiting,
            "active_conversations": active,
            "online_agents": online_agents,
            "total_agents": len(queue.agents),
            "sla_first_response": queue.sla_first_response,
        })

    return result


@router.get("/conversations/critical")
def get_critical_conversations(
    db: Session = Depends(get_db),
    current_user: User = Depends(require_supervisor),
):
    """Get conversations that need attention (SLA breach, long wait, etc.)"""
    now = datetime.utcnow()

    # Conversations waiting more than 5 minutes
    long_wait = db.query(Conversation).filter(
        Conversation.status == ConversationStatus.WAITING,
        Conversation.last_message_at < now - timedelta(minutes=5)
    ).all()

    # High priority conversations
    high_priority = db.query(Conversation).filter(
        Conversation.priority.in_(["high", "urgent"]),
        Conversation.status.in_([ConversationStatus.WAITING, ConversationStatus.ACTIVE])
    ).all()

    result = []
    seen_ids = set()

    for conv in long_wait + high_priority:
        if conv.id in seen_ids:
            continue
        seen_ids.add(conv.id)

        result.append({
            "id": str(conv.id),
            "contact_name": conv.contact.name if conv.contact else "Unknown",
            "contact_phone": conv.contact.phone_number if conv.contact else "",
            "status": conv.status.value,
            "priority": conv.priority,
            "assigned_to": str(conv.assigned_to) if conv.assigned_to else None,
            "last_message_at": conv.last_message_at.isoformat() if conv.last_message_at else None,
            "reason": "long_wait" if conv in long_wait else "high_priority",
        })

    return result

Add to main.py:

from app.routers import auth, whatsapp, flows, queues, supervisor

app.include_router(supervisor.router)

Commit: feat(fase3): add supervisor dashboard API


Task 10: Frontend - Queue Management Page

Files:

  • Create: frontend/src/pages/Queues.tsx

Code:

// frontend/src/pages/Queues.tsx
import { useState } from 'react';
import {
  Card,
  Table,
  Button,
  Modal,
  Form,
  Input,
  InputNumber,
  Select,
  Space,
  Tag,
  Popconfirm,
  message,
  Typography,
  Tabs,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';

const { Title } = Typography;

interface Queue {
  id: string;
  name: string;
  description: string | null;
  assignment_method: string;
  max_per_agent: number;
  sla_first_response: number;
  sla_resolution: number;
  is_active: boolean;
  agent_count: number;
}

interface Agent {
  id: string;
  name: string;
  email: string;
  role: string;
  status: string;
}

export default function Queues() {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [isAgentsModalOpen, setIsAgentsModalOpen] = useState(false);
  const [selectedQueue, setSelectedQueue] = useState<Queue | null>(null);
  const [form] = Form.useForm();
  const queryClient = useQueryClient();

  const { data: queues, isLoading } = useQuery({
    queryKey: ['queues'],
    queryFn: () => apiClient.get<Queue[]>('/api/queues'),
  });

  const { data: agents } = useQuery({
    queryKey: ['agents'],
    queryFn: () => apiClient.get<Agent[]>('/api/auth/agents'),
  });

  const createMutation = useMutation({
    mutationFn: (data: Partial<Queue>) => apiClient.post('/api/queues', data),
    onSuccess: () => {
      message.success('Cola creada');
      queryClient.invalidateQueries({ queryKey: ['queues'] });
      setIsModalOpen(false);
      form.resetFields();
    },
  });

  const updateMutation = useMutation({
    mutationFn: ({ id, data }: { id: string; data: Partial<Queue> }) =>
      apiClient.put(`/api/queues/${id}`, data),
    onSuccess: () => {
      message.success('Cola actualizada');
      queryClient.invalidateQueries({ queryKey: ['queues'] });
      setIsModalOpen(false);
      setSelectedQueue(null);
      form.resetFields();
    },
  });

  const deleteMutation = useMutation({
    mutationFn: (id: string) => apiClient.delete(`/api/queues/${id}`),
    onSuccess: () => {
      message.success('Cola eliminada');
      queryClient.invalidateQueries({ queryKey: ['queues'] });
    },
  });

  const handleSubmit = (values: Partial<Queue>) => {
    if (selectedQueue) {
      updateMutation.mutate({ id: selectedQueue.id, data: values });
    } else {
      createMutation.mutate(values);
    }
  };

  const handleEdit = (queue: Queue) => {
    setSelectedQueue(queue);
    form.setFieldsValue(queue);
    setIsModalOpen(true);
  };

  const columns = [
    {
      title: 'Nombre',
      dataIndex: 'name',
      key: 'name',
    },
    {
      title: 'Método Asignación',
      dataIndex: 'assignment_method',
      key: 'assignment_method',
      render: (method: string) => {
        const labels: Record<string, string> = {
          round_robin: 'Round Robin',
          least_busy: 'Menos Ocupado',
          skill_based: 'Por Habilidades',
        };
        return labels[method] || method;
      },
    },
    {
      title: 'Agentes',
      dataIndex: 'agent_count',
      key: 'agent_count',
      render: (count: number) => <Tag icon={<TeamOutlined />}>{count}</Tag>,
    },
    {
      title: 'Max/Agente',
      dataIndex: 'max_per_agent',
      key: 'max_per_agent',
    },
    {
      title: 'SLA Respuesta',
      dataIndex: 'sla_first_response',
      key: 'sla_first_response',
      render: (seconds: number) => `${Math.round(seconds / 60)} min`,
    },
    {
      title: 'Acciones',
      key: 'actions',
      render: (_: unknown, record: Queue) => (
        <Space>
          <Button
            size="small"
            icon={<TeamOutlined />}
            onClick={() => {
              setSelectedQueue(record);
              setIsAgentsModalOpen(true);
            }}
          >
            Agentes
          </Button>
          <Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
          <Popconfirm
            title="¿Eliminar cola?"
            onConfirm={() => deleteMutation.mutate(record.id)}
          >
            <Button size="small" danger icon={<DeleteOutlined />} />
          </Popconfirm>
        </Space>
      ),
    },
  ];

  return (
    <div>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
        <Title level={4}>Colas de Atención</Title>
        <Button
          type="primary"
          icon={<PlusOutlined />}
          onClick={() => {
            setSelectedQueue(null);
            form.resetFields();
            setIsModalOpen(true);
          }}
        >
          Nueva Cola
        </Button>
      </div>

      <Card>
        <Table
          dataSource={queues}
          columns={columns}
          rowKey="id"
          loading={isLoading}
          pagination={false}
        />
      </Card>

      <Modal
        title={selectedQueue ? 'Editar Cola' : 'Nueva Cola'}
        open={isModalOpen}
        onCancel={() => {
          setIsModalOpen(false);
          setSelectedQueue(null);
          form.resetFields();
        }}
        footer={null}
      >
        <Form form={form} layout="vertical" onFinish={handleSubmit}>
          <Form.Item name="name" label="Nombre" rules={[{ required: true }]}>
            <Input />
          </Form.Item>
          <Form.Item name="description" label="Descripción">
            <Input.TextArea rows={2} />
          </Form.Item>
          <Form.Item name="assignment_method" label="Método de Asignación" initialValue="round_robin">
            <Select>
              <Select.Option value="round_robin">Round Robin</Select.Option>
              <Select.Option value="least_busy">Menos Ocupado</Select.Option>
              <Select.Option value="skill_based">Por Habilidades</Select.Option>
            </Select>
          </Form.Item>
          <Form.Item name="max_per_agent" label="Máximo por Agente" initialValue={10}>
            <InputNumber min={1} max={50} />
          </Form.Item>
          <Form.Item name="sla_first_response" label="SLA Primera Respuesta (segundos)" initialValue={300}>
            <InputNumber min={60} step={60} />
          </Form.Item>
          <Form.Item>
            <Space>
              <Button type="primary" htmlType="submit" loading={createMutation.isPending || updateMutation.isPending}>
                {selectedQueue ? 'Actualizar' : 'Crear'}
              </Button>
              <Button onClick={() => setIsModalOpen(false)}>Cancelar</Button>
            </Space>
          </Form.Item>
        </Form>
      </Modal>

      <Modal
        title={`Agentes - ${selectedQueue?.name}`}
        open={isAgentsModalOpen}
        onCancel={() => setIsAgentsModalOpen(false)}
        footer={null}
        width={600}
      >
        <p>Gestión de agentes en cola (próximamente)</p>
      </Modal>
    </div>
  );
}

Commit: feat(fase3): add Queue management frontend page


Task 11: Frontend - Enhanced Inbox

Files:

  • Modify: frontend/src/pages/Inbox.tsx

Replace entire file with enhanced version:

// frontend/src/pages/Inbox.tsx
import { useState } from 'react';
import {
  Card,
  List,
  Avatar,
  Typography,
  Input,
  Button,
  Tag,
  Empty,
  Spin,
  Space,
  Badge,
  Dropdown,
  Divider,
  Select,
  Tooltip,
  Modal,
  message,
} from 'antd';
import {
  SendOutlined,
  UserOutlined,
  MoreOutlined,
  SwapOutlined,
  RobotOutlined,
  CheckCircleOutlined,
  MessageOutlined,
  FileTextOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/es';

dayjs.extend(relativeTime);
dayjs.locale('es');

const { Text } = Typography;
const { TextArea } = Input;

interface Contact {
  id: string;
  phone_number: string;
  name: string | null;
}

interface Message {
  id: string;
  direction: 'inbound' | 'outbound';
  type: string;
  content: string | null;
  created_at: string;
  is_internal_note: boolean;
  sent_by: string | null;
}

interface Conversation {
  id: string;
  contact: Contact;
  status: string;
  priority: string;
  assigned_to: string | null;
  queue_id: string | null;
  last_message_at: string | null;
  messages?: Message[];
}

interface Queue {
  id: string;
  name: string;
}

interface Agent {
  id: string;
  name: string;
  status: string;
}

export default function Inbox() {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [messageText, setMessageText] = useState('');
  const [statusFilter, setStatusFilter] = useState<string | null>(null);
  const [isNoteMode, setIsNoteMode] = useState(false);
  const [transferModalOpen, setTransferModalOpen] = useState(false);
  const [transferType, setTransferType] = useState<'queue' | 'agent'>('queue');
  const queryClient = useQueryClient();

  const { data: conversations, isLoading } = useQuery({
    queryKey: ['conversations', statusFilter],
    queryFn: async () => {
      const url = statusFilter
        ? `/api/whatsapp/conversations?status=${statusFilter}`
        : '/api/whatsapp/conversations';
      return apiClient.get<Conversation[]>(url);
    },
    refetchInterval: 3000,
  });

  const { data: selectedConversation } = useQuery({
    queryKey: ['conversation', selectedId],
    queryFn: async () => {
      if (!selectedId) return null;
      return apiClient.get<Conversation>(`/api/whatsapp/conversations/${selectedId}`);
    },
    enabled: !!selectedId,
    refetchInterval: 2000,
  });

  const { data: queues } = useQuery({
    queryKey: ['queues'],
    queryFn: () => apiClient.get<Queue[]>('/api/queues'),
  });

  const { data: agents } = useQuery({
    queryKey: ['agents'],
    queryFn: () => apiClient.get<Agent[]>('/api/auth/agents'),
  });

  const { data: quickReplies } = useQuery({
    queryKey: ['quick-replies'],
    queryFn: () => apiClient.get<{ shortcut: string; content: string }[]>('/api/queues/quick-replies'),
  });

  const sendMutation = useMutation({
    mutationFn: async (data: { conversationId: string; content: string }) => {
      await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/messages`, {
        type: 'text',
        content: data.content,
      });
    },
    onSuccess: () => {
      setMessageText('');
      queryClient.invalidateQueries({ queryKey: ['conversation', selectedId] });
      queryClient.invalidateQueries({ queryKey: ['conversations'] });
    },
  });

  const noteMutation = useMutation({
    mutationFn: async (data: { conversationId: string; content: string }) => {
      await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/notes`, {
        content: data.content,
      });
    },
    onSuccess: () => {
      setMessageText('');
      setIsNoteMode(false);
      message.success('Nota agregada');
      queryClient.invalidateQueries({ queryKey: ['conversation', selectedId] });
    },
  });

  const transferQueueMutation = useMutation({
    mutationFn: async (data: { conversationId: string; queueId: string }) => {
      await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/transfer-to-queue`, {
        queue_id: data.queueId,
      });
    },
    onSuccess: () => {
      message.success('Transferido a cola');
      setTransferModalOpen(false);
      queryClient.invalidateQueries({ queryKey: ['conversations'] });
    },
  });

  const transferAgentMutation = useMutation({
    mutationFn: async (data: { conversationId: string; agentId: string }) => {
      await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/transfer-to-agent`, {
        agent_id: data.agentId,
      });
    },
    onSuccess: () => {
      message.success('Transferido a agente');
      setTransferModalOpen(false);
      queryClient.invalidateQueries({ queryKey: ['conversations'] });
    },
  });

  const transferBotMutation = useMutation({
    mutationFn: async (conversationId: string) => {
      await apiClient.post(`/api/whatsapp/conversations/${conversationId}/transfer-to-bot`, {});
    },
    onSuccess: () => {
      message.success('Transferido a bot');
      queryClient.invalidateQueries({ queryKey: ['conversations'] });
    },
  });

  const resolveMutation = useMutation({
    mutationFn: async (conversationId: string) => {
      await apiClient.post(`/api/whatsapp/conversations/${conversationId}/resolve`, {});
    },
    onSuccess: () => {
      message.success('Conversación resuelta');
      queryClient.invalidateQueries({ queryKey: ['conversations'] });
    },
  });

  const handleSend = () => {
    if (!messageText.trim() || !selectedId) return;
    if (isNoteMode) {
      noteMutation.mutate({ conversationId: selectedId, content: messageText });
    } else {
      sendMutation.mutate({ conversationId: selectedId, content: messageText });
    }
  };

  const handleQuickReply = (content: string) => {
    setMessageText(content);
  };

  const statusColors: Record<string, string> = {
    bot: 'blue',
    waiting: 'orange',
    active: 'green',
    resolved: 'default',
  };

  const priorityColors: Record<string, string> = {
    low: 'default',
    normal: 'blue',
    high: 'orange',
    urgent: 'red',
  };

  const actionMenuItems = [
    {
      key: 'transfer-queue',
      icon: <SwapOutlined />,
      label: 'Transferir a cola',
      onClick: () => {
        setTransferType('queue');
        setTransferModalOpen(true);
      },
    },
    {
      key: 'transfer-agent',
      icon: <UserOutlined />,
      label: 'Transferir a agente',
      onClick: () => {
        setTransferType('agent');
        setTransferModalOpen(true);
      },
    },
    {
      key: 'transfer-bot',
      icon: <RobotOutlined />,
      label: 'Transferir a bot',
      onClick: () => selectedId && transferBotMutation.mutate(selectedId),
    },
    { type: 'divider' as const },
    {
      key: 'resolve',
      icon: <CheckCircleOutlined />,
      label: 'Resolver conversación',
      onClick: () => selectedId && resolveMutation.mutate(selectedId),
    },
  ];

  return (
    <div style={{ display: 'flex', height: 'calc(100vh - 180px)', gap: 16 }}>
      {/* Lista de conversaciones */}
      <Card
        style={{ width: 380, overflow: 'auto' }}
        styles={{ body: { padding: 0 } }}
        title={
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
            <span>Conversaciones</span>
            <Select
              placeholder="Filtrar"
              allowClear
              size="small"
              style={{ width: 120 }}
              value={statusFilter}
              onChange={setStatusFilter}
            >
              <Select.Option value="bot">Bot</Select.Option>
              <Select.Option value="waiting">En espera</Select.Option>
              <Select.Option value="active">Activas</Select.Option>
              <Select.Option value="resolved">Resueltas</Select.Option>
            </Select>
          </div>
        }
      >
        {isLoading ? (
          <div style={{ padding: 40, textAlign: 'center' }}>
            <Spin />
          </div>
        ) : conversations?.length === 0 ? (
          <Empty description="Sin conversaciones" style={{ padding: 40 }} />
        ) : (
          <List
            dataSource={conversations}
            renderItem={(conv) => (
              <List.Item
                onClick={() => setSelectedId(conv.id)}
                style={{
                  padding: '12px 16px',
                  cursor: 'pointer',
                  background: selectedId === conv.id ? '#f5f5f5' : 'transparent',
                }}
              >
                <List.Item.Meta
                  avatar={
                    <Badge dot={conv.status !== 'resolved'} color={statusColors[conv.status]}>
                      <Avatar icon={<UserOutlined />} />
                    </Badge>
                  }
                  title={
                    <Space>
                      <Text strong>{conv.contact.name || conv.contact.phone_number}</Text>
                      <Tag color={statusColors[conv.status]} style={{ fontSize: 10 }}>
                        {conv.status}
                      </Tag>
                      {conv.priority !== 'normal' && (
                        <Tag color={priorityColors[conv.priority]} style={{ fontSize: 10 }}>
                          {conv.priority}
                        </Tag>
                      )}
                    </Space>
                  }
                  description={
                    <Text type="secondary" style={{ fontSize: 12 }}>
                      {conv.last_message_at ? dayjs(conv.last_message_at).fromNow() : 'Sin mensajes'}
                    </Text>
                  }
                />
              </List.Item>
            )}
          />
        )}
      </Card>

      {/* Chat */}
      <Card
        style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
        styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0 } }}
      >
        {selectedConversation ? (
          <>
            {/* Header */}
            <div
              style={{
                padding: 16,
                borderBottom: '1px solid #f0f0f0',
                display: 'flex',
                justifyContent: 'space-between',
                alignItems: 'center',
              }}
            >
              <div>
                <Text strong style={{ fontSize: 16 }}>
                  {selectedConversation.contact.name || selectedConversation.contact.phone_number}
                </Text>
                <br />
                <Text type="secondary">{selectedConversation.contact.phone_number}</Text>
              </div>
              <Space>
                <Tag color={statusColors[selectedConversation.status]}>
                  {selectedConversation.status}
                </Tag>
                <Dropdown menu={{ items: actionMenuItems }} trigger={['click']}>
                  <Button icon={<MoreOutlined />} />
                </Dropdown>
              </Space>
            </div>

            {/* Messages */}
            <div
              style={{
                flex: 1,
                overflow: 'auto',
                padding: 16,
                display: 'flex',
                flexDirection: 'column',
                gap: 8,
              }}
            >
              {selectedConversation.messages?.map((msg) => (
                <div
                  key={msg.id}
                  style={{
                    alignSelf: msg.direction === 'outbound' ? 'flex-end' : 'flex-start',
                    maxWidth: '70%',
                  }}
                >
                  <div
                    style={{
                      padding: '8px 12px',
                      borderRadius: 8,
                      background: msg.is_internal_note
                        ? '#fff7e6'
                        : msg.direction === 'outbound'
                        ? '#25D366'
                        : '#f0f0f0',
                      color: msg.is_internal_note ? '#d46b08' : msg.direction === 'outbound' ? 'white' : 'inherit',
                      border: msg.is_internal_note ? '1px dashed #ffc069' : 'none',
                    }}
                  >
                    {msg.is_internal_note && (
                      <Text type="secondary" style={{ fontSize: 10, display: 'block', marginBottom: 4 }}>
                        <FileTextOutlined /> Nota interna
                      </Text>
                    )}
                    <Text style={{ color: 'inherit' }}>{msg.content}</Text>
                  </div>
                  <Text
                    type="secondary"
                    style={{
                      fontSize: 10,
                      display: 'block',
                      textAlign: msg.direction === 'outbound' ? 'right' : 'left',
                      marginTop: 4,
                    }}
                  >
                    {dayjs(msg.created_at).format('HH:mm')}
                  </Text>
                </div>
              ))}
            </div>

            {/* Quick Replies */}
            {quickReplies && quickReplies.length > 0 && (
              <div style={{ padding: '8px 16px', borderTop: '1px solid #f0f0f0' }}>
                <Space wrap size={4}>
                  {quickReplies.slice(0, 5).map((qr) => (
                    <Tooltip key={qr.shortcut} title={qr.content}>
                      <Tag
                        style={{ cursor: 'pointer' }}
                        onClick={() => handleQuickReply(qr.content)}
                      >
                        {qr.shortcut}
                      </Tag>
                    </Tooltip>
                  ))}
                </Space>
              </div>
            )}

            {/* Input */}
            <div
              style={{
                padding: 16,
                borderTop: '1px solid #f0f0f0',
              }}
            >
              <div style={{ marginBottom: 8 }}>
                <Space>
                  <Button
                    size="small"
                    type={isNoteMode ? 'default' : 'primary'}
                    icon={<MessageOutlined />}
                    onClick={() => setIsNoteMode(false)}
                  >
                    Mensaje
                  </Button>
                  <Button
                    size="small"
                    type={isNoteMode ? 'primary' : 'default'}
                    icon={<FileTextOutlined />}
                    onClick={() => setIsNoteMode(true)}
                    style={isNoteMode ? { background: '#d46b08', borderColor: '#d46b08' } : {}}
                  >
                    Nota interna
                  </Button>
                </Space>
              </div>
              <div style={{ display: 'flex', gap: 8 }}>
                <Input
                  placeholder={isNoteMode ? 'Escribe una nota interna...' : 'Escribe un mensaje...'}
                  value={messageText}
                  onChange={(e) => setMessageText(e.target.value)}
                  onPressEnter={handleSend}
                  style={isNoteMode ? { borderColor: '#ffc069' } : {}}
                />
                <Button
                  type="primary"
                  icon={<SendOutlined />}
                  onClick={handleSend}
                  loading={sendMutation.isPending || noteMutation.isPending}
                  style={isNoteMode ? { background: '#d46b08', borderColor: '#d46b08' } : { background: '#25D366', borderColor: '#25D366' }}
                />
              </div>
            </div>
          </>
        ) : (
          <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
            <Empty description="Selecciona una conversación" />
          </div>
        )}
      </Card>

      {/* Transfer Modal */}
      <Modal
        title={transferType === 'queue' ? 'Transferir a Cola' : 'Transferir a Agente'}
        open={transferModalOpen}
        onCancel={() => setTransferModalOpen(false)}
        footer={null}
      >
        {transferType === 'queue' ? (
          <Select
            placeholder="Seleccionar cola"
            style={{ width: '100%' }}
            onChange={(value) => {
              if (selectedId) {
                transferQueueMutation.mutate({ conversationId: selectedId, queueId: value });
              }
            }}
          >
            {queues?.map((q) => (
              <Select.Option key={q.id} value={q.id}>
                {q.name}
              </Select.Option>
            ))}
          </Select>
        ) : (
          <Select
            placeholder="Seleccionar agente"
            style={{ width: '100%' }}
            onChange={(value) => {
              if (selectedId) {
                transferAgentMutation.mutate({ conversationId: selectedId, agentId: value });
              }
            }}
          >
            {agents
              ?.filter((a) => a.status === 'online')
              .map((a) => (
                <Select.Option key={a.id} value={a.id}>
                  {a.name} ({a.status})
                </Select.Option>
              ))}
          </Select>
        )}
      </Modal>
    </div>
  );
}

Commit: feat(fase3): enhance Inbox with transfers, notes, and quick replies


Task 12: Frontend - Supervisor Dashboard

Files:

  • Create: frontend/src/pages/SupervisorDashboard.tsx

Code:

// frontend/src/pages/SupervisorDashboard.tsx
import { useState } from 'react';
import {
  Card,
  Row,
  Col,
  Statistic,
  Table,
  Tag,
  Typography,
  Badge,
  Button,
  Space,
  Tabs,
  List,
  Avatar,
  Alert,
} from 'antd';
import {
  TeamOutlined,
  MessageOutlined,
  ClockCircleOutlined,
  CheckCircleOutlined,
  ExclamationCircleOutlined,
  ReloadOutlined,
  UserOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import dayjs from 'dayjs';

const { Title, Text } = Typography;

interface DashboardData {
  conversations: {
    by_status: Record<string, number>;
    waiting: number;
    resolved_today: number;
  };
  agents: {
    online: number;
    total: number;
  };
  messages_today: number;
  avg_csat: number | null;
}

interface AgentStatus {
  id: string;
  name: string;
  email: string;
  role: string;
  status: string;
  active_conversations: number;
  waiting_conversations: number;
  resolved_today: number;
}

interface QueueStatus {
  id: string;
  name: string;
  waiting_conversations: number;
  active_conversations: number;
  online_agents: number;
  total_agents: number;
}

interface CriticalConversation {
  id: string;
  contact_name: string;
  contact_phone: string;
  status: string;
  priority: string;
  last_message_at: string | null;
  reason: string;
}

export default function SupervisorDashboard() {
  const { data: dashboard, isLoading: dashboardLoading, refetch: refetchDashboard } = useQuery({
    queryKey: ['supervisor-dashboard'],
    queryFn: () => apiClient.get<DashboardData>('/api/supervisor/dashboard'),
    refetchInterval: 10000,
  });

  const { data: agents, refetch: refetchAgents } = useQuery({
    queryKey: ['supervisor-agents'],
    queryFn: () => apiClient.get<AgentStatus[]>('/api/supervisor/agents'),
    refetchInterval: 10000,
  });

  const { data: queues, refetch: refetchQueues } = useQuery({
    queryKey: ['supervisor-queues'],
    queryFn: () => apiClient.get<QueueStatus[]>('/api/supervisor/queues'),
    refetchInterval: 10000,
  });

  const { data: criticalConversations } = useQuery({
    queryKey: ['critical-conversations'],
    queryFn: () => apiClient.get<CriticalConversation[]>('/api/supervisor/conversations/critical'),
    refetchInterval: 15000,
  });

  const handleRefresh = () => {
    refetchDashboard();
    refetchAgents();
    refetchQueues();
  };

  const statusColors: Record<string, string> = {
    online: 'green',
    offline: 'default',
    away: 'orange',
    busy: 'red',
  };

  const agentColumns = [
    {
      title: 'Agente',
      dataIndex: 'name',
      key: 'name',
      render: (name: string, record: AgentStatus) => (
        <Space>
          <Badge status={record.status === 'online' ? 'success' : 'default'} />
          <Text>{name}</Text>
        </Space>
      ),
    },
    {
      title: 'Estado',
      dataIndex: 'status',
      key: 'status',
      render: (status: string) => <Tag color={statusColors[status]}>{status}</Tag>,
    },
    {
      title: 'Activas',
      dataIndex: 'active_conversations',
      key: 'active',
      render: (count: number) => <Badge count={count} showZero color={count > 0 ? 'blue' : 'default'} />,
    },
    {
      title: 'En Espera',
      dataIndex: 'waiting_conversations',
      key: 'waiting',
      render: (count: number) => (
        <Badge count={count} showZero color={count > 0 ? 'orange' : 'default'} />
      ),
    },
    {
      title: 'Resueltas Hoy',
      dataIndex: 'resolved_today',
      key: 'resolved',
    },
  ];

  const queueColumns = [
    {
      title: 'Cola',
      dataIndex: 'name',
      key: 'name',
    },
    {
      title: 'En Espera',
      dataIndex: 'waiting_conversations',
      key: 'waiting',
      render: (count: number) => (
        <Badge count={count} showZero color={count > 0 ? 'orange' : 'default'} />
      ),
    },
    {
      title: 'Activas',
      dataIndex: 'active_conversations',
      key: 'active',
      render: (count: number) => <Badge count={count} showZero color={count > 0 ? 'blue' : 'default'} />,
    },
    {
      title: 'Agentes Online',
      key: 'agents',
      render: (_: unknown, record: QueueStatus) => (
        <Text>
          {record.online_agents}/{record.total_agents}
        </Text>
      ),
    },
  ];

  return (
    <div>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
        <Title level={4}>Panel de Supervisor</Title>
        <Button icon={<ReloadOutlined />} onClick={handleRefresh}>
          Actualizar
        </Button>
      </div>

      {/* Critical Alerts */}
      {criticalConversations && criticalConversations.length > 0 && (
        <Alert
          message={`${criticalConversations.length} conversaciones requieren atención`}
          type="warning"
          showIcon
          icon={<ExclamationCircleOutlined />}
          style={{ marginBottom: 16 }}
          action={
            <Button size="small" type="link">
              Ver detalles
            </Button>
          }
        />
      )}

      {/* Stats */}
      <Row gutter={16} style={{ marginBottom: 24 }}>
        <Col span={6}>
          <Card>
            <Statistic
              title="Agentes Online"
              value={dashboard?.agents.online || 0}
              suffix={`/ ${dashboard?.agents.total || 0}`}
              prefix={<TeamOutlined />}
              valueStyle={{ color: '#52c41a' }}
            />
          </Card>
        </Col>
        <Col span={6}>
          <Card>
            <Statistic
              title="En Espera"
              value={dashboard?.conversations.waiting || 0}
              prefix={<ClockCircleOutlined />}
              valueStyle={{ color: dashboard?.conversations.waiting ? '#faad14' : undefined }}
            />
          </Card>
        </Col>
        <Col span={6}>
          <Card>
            <Statistic
              title="Resueltas Hoy"
              value={dashboard?.conversations.resolved_today || 0}
              prefix={<CheckCircleOutlined />}
              valueStyle={{ color: '#52c41a' }}
            />
          </Card>
        </Col>
        <Col span={6}>
          <Card>
            <Statistic
              title="Mensajes Hoy"
              value={dashboard?.messages_today || 0}
              prefix={<MessageOutlined />}
            />
          </Card>
        </Col>
      </Row>

      {/* Status by type */}
      <Row gutter={16} style={{ marginBottom: 24 }}>
        <Col span={6}>
          <Card size="small">
            <Statistic
              title="Bot"
              value={dashboard?.conversations.by_status?.bot || 0}
              valueStyle={{ fontSize: 20, color: '#1890ff' }}
            />
          </Card>
        </Col>
        <Col span={6}>
          <Card size="small">
            <Statistic
              title="Esperando"
              value={dashboard?.conversations.by_status?.waiting || 0}
              valueStyle={{ fontSize: 20, color: '#faad14' }}
            />
          </Card>
        </Col>
        <Col span={6}>
          <Card size="small">
            <Statistic
              title="Activas"
              value={dashboard?.conversations.by_status?.active || 0}
              valueStyle={{ fontSize: 20, color: '#52c41a' }}
            />
          </Card>
        </Col>
        <Col span={6}>
          <Card size="small">
            <Statistic
              title="CSAT Promedio"
              value={dashboard?.avg_csat || '-'}
              suffix={dashboard?.avg_csat ? '/ 5' : ''}
              valueStyle={{ fontSize: 20 }}
            />
          </Card>
        </Col>
      </Row>

      {/* Tables */}
      <Tabs
        items={[
          {
            key: 'agents',
            label: 'Estado de Agentes',
            children: (
              <Card>
                <Table
                  dataSource={agents}
                  columns={agentColumns}
                  rowKey="id"
                  pagination={false}
                  size="small"
                />
              </Card>
            ),
          },
          {
            key: 'queues',
            label: 'Estado de Colas',
            children: (
              <Card>
                <Table
                  dataSource={queues}
                  columns={queueColumns}
                  rowKey="id"
                  pagination={false}
                  size="small"
                />
              </Card>
            ),
          },
          {
            key: 'critical',
            label: (
              <span>
                Críticas{' '}
                {criticalConversations && criticalConversations.length > 0 && (
                  <Badge count={criticalConversations.length} />
                )}
              </span>
            ),
            children: (
              <Card>
                {criticalConversations && criticalConversations.length > 0 ? (
                  <List
                    dataSource={criticalConversations}
                    renderItem={(conv) => (
                      <List.Item>
                        <List.Item.Meta
                          avatar={<Avatar icon={<UserOutlined />} />}
                          title={conv.contact_name || conv.contact_phone}
                          description={
                            <Space>
                              <Tag color={conv.reason === 'long_wait' ? 'orange' : 'red'}>
                                {conv.reason === 'long_wait' ? 'Espera prolongada' : 'Alta prioridad'}
                              </Tag>
                              <Text type="secondary">
                                {conv.last_message_at
                                  ? dayjs(conv.last_message_at).fromNow()
                                  : 'Sin mensajes'}
                              </Text>
                            </Space>
                          }
                        />
                      </List.Item>
                    )}
                  />
                ) : (
                  <Text type="secondary">No hay conversaciones críticas</Text>
                )}
              </Card>
            ),
          },
        ]}
      />
    </div>
  );
}

Commit: feat(fase3): add Supervisor Dashboard frontend


Task 13: Update MainLayout with New Routes

Files:

  • Modify: frontend/src/layouts/MainLayout.tsx

Add imports:

import {
  // ... existing imports ...
  TeamOutlined,
  BarChartOutlined,
} from '@ant-design/icons';

import Queues from '../pages/Queues';
import SupervisorDashboard from '../pages/SupervisorDashboard';

Add menu items (after Flujos, before Configuración):

    {
      key: '/queues',
      icon: <TeamOutlined />,
      label: 'Colas',
    },
    {
      key: '/supervisor',
      icon: <BarChartOutlined />,
      label: 'Supervisor',
    },

Add routes (inside Routes component):

            <Route path="/queues" element={<Queues />} />
            <Route path="/supervisor" element={<SupervisorDashboard />} />

Commit: feat(fase3): add Queues and Supervisor routes to MainLayout


Task 14: Final Integration - Update Docker Compose

Files:

  • No changes needed to docker-compose.yml for this phase

Final Commit: feat(fase3): complete Inbox Avanzado + Multi-agente phase


Summary

This plan implements:

  1. Database Models: Queue, QueueAgent, QuickReply, extended Conversation
  2. Backend Services: AssignmentService for intelligent queue distribution
  3. API Endpoints:
    • Queue CRUD and agent management
    • Quick replies management
    • Conversation transfers (queue, agent, bot)
    • Internal notes
    • Agent status updates
    • Supervisor dashboard data
  4. Frontend Pages:
    • Queue management
    • Enhanced Inbox with transfers and notes
    • Supervisor dashboard with real-time stats

Key Features:

  • Round-robin, least-busy, and skill-based assignment
  • Bot ↔ Human ↔ Bot transfers
  • Internal notes (not visible to customers)
  • Quick replies for faster responses
  • Real-time supervisor monitoring
  • SLA tracking foundation
  • CSAT collection