# 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:** ```python # 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`:** ```python 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):** ```python # 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:** ```python 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:** ```python # 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`:** ```python 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:** ```python # 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:** ```python # 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:** ```python 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:** ```python # 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:** ```python # 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):** ```python 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:** ```python from app.models.user import UserStatus class UpdateStatusRequest(BaseModel): status: UserStatus ``` **Add to routers/auth.py:** ```python 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:** ```python # 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:** ```python 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:** ```tsx // 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(null); const [form] = Form.useForm(); const queryClient = useQueryClient(); const { data: queues, isLoading } = useQuery({ queryKey: ['queues'], queryFn: () => apiClient.get('/api/queues'), }); const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => apiClient.get('/api/auth/agents'), }); const createMutation = useMutation({ mutationFn: (data: Partial) => 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 }) => 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) => { 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 = { 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) => }>{count}, }, { 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) => ( { setIsModalOpen(false); setSelectedQueue(null); form.resetFields(); }} footer={null} >
setIsAgentsModalOpen(false)} footer={null} width={600} >

Gestión de agentes en cola (próximamente)

); } ``` **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:** ```tsx // 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(null); const [messageText, setMessageText] = useState(''); const [statusFilter, setStatusFilter] = useState(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(url); }, refetchInterval: 3000, }); const { data: selectedConversation } = useQuery({ queryKey: ['conversation', selectedId], queryFn: async () => { if (!selectedId) return null; return apiClient.get(`/api/whatsapp/conversations/${selectedId}`); }, enabled: !!selectedId, refetchInterval: 2000, }); const { data: queues } = useQuery({ queryKey: ['queues'], queryFn: () => apiClient.get('/api/queues'), }); const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => apiClient.get('/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 = { bot: 'blue', waiting: 'orange', active: 'green', resolved: 'default', }; const priorityColors: Record = { low: 'default', normal: 'blue', high: 'orange', urgent: 'red', }; const actionMenuItems = [ { key: 'transfer-queue', icon: , label: 'Transferir a cola', onClick: () => { setTransferType('queue'); setTransferModalOpen(true); }, }, { key: 'transfer-agent', icon: , label: 'Transferir a agente', onClick: () => { setTransferType('agent'); setTransferModalOpen(true); }, }, { key: 'transfer-bot', icon: , label: 'Transferir a bot', onClick: () => selectedId && transferBotMutation.mutate(selectedId), }, { type: 'divider' as const }, { key: 'resolve', icon: , label: 'Resolver conversación', onClick: () => selectedId && resolveMutation.mutate(selectedId), }, ]; return (
{/* Lista de conversaciones */} Conversaciones
} > {isLoading ? (
) : conversations?.length === 0 ? ( ) : ( ( setSelectedId(conv.id)} style={{ padding: '12px 16px', cursor: 'pointer', background: selectedId === conv.id ? '#f5f5f5' : 'transparent', }} > } /> } title={ {conv.contact.name || conv.contact.phone_number} {conv.status} {conv.priority !== 'normal' && ( {conv.priority} )} } description={ {conv.last_message_at ? dayjs(conv.last_message_at).fromNow() : 'Sin mensajes'} } /> )} /> )} {/* Chat */} {selectedConversation ? ( <> {/* Header */}
{selectedConversation.contact.name || selectedConversation.contact.phone_number}
{selectedConversation.contact.phone_number}
{selectedConversation.status}
{/* Messages */}
{selectedConversation.messages?.map((msg) => (
{msg.is_internal_note && ( Nota interna )} {msg.content}
{dayjs(msg.created_at).format('HH:mm')}
))}
{/* Quick Replies */} {quickReplies && quickReplies.length > 0 && (
{quickReplies.slice(0, 5).map((qr) => ( handleQuickReply(qr.content)} > {qr.shortcut} ))}
)} {/* Input */}
setMessageText(e.target.value)} onPressEnter={handleSend} style={isNoteMode ? { borderColor: '#ffc069' } : {}} />
) : (
)}
{/* Transfer Modal */} setTransferModalOpen(false)} footer={null} > {transferType === 'queue' ? ( ) : ( )} ); } ``` **Commit:** `feat(fase3): enhance Inbox with transfers, notes, and quick replies` --- ## Task 12: Frontend - Supervisor Dashboard **Files:** - Create: `frontend/src/pages/SupervisorDashboard.tsx` **Code:** ```tsx // 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; 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('/api/supervisor/dashboard'), refetchInterval: 10000, }); const { data: agents, refetch: refetchAgents } = useQuery({ queryKey: ['supervisor-agents'], queryFn: () => apiClient.get('/api/supervisor/agents'), refetchInterval: 10000, }); const { data: queues, refetch: refetchQueues } = useQuery({ queryKey: ['supervisor-queues'], queryFn: () => apiClient.get('/api/supervisor/queues'), refetchInterval: 10000, }); const { data: criticalConversations } = useQuery({ queryKey: ['critical-conversations'], queryFn: () => apiClient.get('/api/supervisor/conversations/critical'), refetchInterval: 15000, }); const handleRefresh = () => { refetchDashboard(); refetchAgents(); refetchQueues(); }; const statusColors: Record = { online: 'green', offline: 'default', away: 'orange', busy: 'red', }; const agentColumns = [ { title: 'Agente', dataIndex: 'name', key: 'name', render: (name: string, record: AgentStatus) => ( {name} ), }, { title: 'Estado', dataIndex: 'status', key: 'status', render: (status: string) => {status}, }, { title: 'Activas', dataIndex: 'active_conversations', key: 'active', render: (count: number) => 0 ? 'blue' : 'default'} />, }, { title: 'En Espera', dataIndex: 'waiting_conversations', key: 'waiting', render: (count: number) => ( 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) => ( 0 ? 'orange' : 'default'} /> ), }, { title: 'Activas', dataIndex: 'active_conversations', key: 'active', render: (count: number) => 0 ? 'blue' : 'default'} />, }, { title: 'Agentes Online', key: 'agents', render: (_: unknown, record: QueueStatus) => ( {record.online_agents}/{record.total_agents} ), }, ]; return (
Panel de Supervisor
{/* Critical Alerts */} {criticalConversations && criticalConversations.length > 0 && ( } style={{ marginBottom: 16 }} action={ } /> )} {/* Stats */}
} valueStyle={{ color: '#52c41a' }} /> } valueStyle={{ color: dashboard?.conversations.waiting ? '#faad14' : undefined }} /> } valueStyle={{ color: '#52c41a' }} /> } /> {/* Status by type */} {/* Tables */}
), }, { key: 'queues', label: 'Estado de Colas', children: (
), }, { key: 'critical', label: ( Críticas{' '} {criticalConversations && criticalConversations.length > 0 && ( )} ), children: ( {criticalConversations && criticalConversations.length > 0 ? ( ( } />} title={conv.contact_name || conv.contact_phone} description={ {conv.reason === 'long_wait' ? 'Espera prolongada' : 'Alta prioridad'} {conv.last_message_at ? dayjs(conv.last_message_at).fromNow() : 'Sin mensajes'} } /> )} /> ) : ( No hay conversaciones críticas )} ), }, ]} /> ); } ``` **Commit:** `feat(fase3): add Supervisor Dashboard frontend` --- ## Task 13: Update MainLayout with New Routes **Files:** - Modify: `frontend/src/layouts/MainLayout.tsx` **Add imports:** ```tsx 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):** ```tsx { key: '/queues', icon: , label: 'Colas', }, { key: '/supervisor', icon: , label: 'Supervisor', }, ``` **Add routes (inside Routes component):** ```tsx } /> } /> ``` **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