diff --git a/docs/plans/2026-01-29-fase-3-inbox-multiagente.md b/docs/plans/2026-01-29-fase-3-inbox-multiagente.md new file mode 100644 index 0000000..65a29e9 --- /dev/null +++ b/docs/plans/2026-01-29-fase-3-inbox-multiagente.md @@ -0,0 +1,2449 @@ +# 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