14 tasks covering: - Queue and QueueAgent database models - QuickReply model for fast responses - AssignmentService with round-robin, least-busy, skill-based - Conversation transfers (queue, agent, bot) - Internal notes system - Agent status management - Supervisor dashboard API - Frontend: Queues page, enhanced Inbox, Supervisor Dashboard Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
71 KiB
Fase 3: Inbox Avanzado + Multi-agente - Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implementar sistema completo de colas, asignación inteligente de conversaciones, transferencia bot↔humano, estados de agente, respuestas rápidas, notas internas y panel de supervisor.
Architecture: Backend con modelos Queue, QueueAgent, QuickReply y campos adicionales en Conversation para SLA tracking. API Gateway expone endpoints para gestión de colas, asignación y transferencias. Frontend añade panel de supervisor, filtros avanzados en Inbox, y componentes para respuestas rápidas y notas internas.
Tech Stack: Python/FastAPI (backend), PostgreSQL (DB), React/TypeScript/Ant Design (frontend)
Task 1: Database Models para Colas y Agentes
Files:
- Create:
services/api-gateway/app/models/queue.py - Modify:
services/api-gateway/app/models/__init__.py
Code:
# services/api-gateway/app/models/queue.py
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Integer, ForeignKey, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from sqlalchemy.orm import relationship
import enum
from app.core.database import Base
class AssignmentMethod(str, enum.Enum):
ROUND_ROBIN = "round_robin"
LEAST_BUSY = "least_busy"
SKILL_BASED = "skill_based"
class Queue(Base):
__tablename__ = "queues"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
description = Column(String(500), nullable=True)
assignment_method = Column(SQLEnum(AssignmentMethod), default=AssignmentMethod.ROUND_ROBIN, nullable=False)
max_per_agent = Column(Integer, default=10, nullable=False)
sla_first_response = Column(Integer, default=300, nullable=False) # seconds
sla_resolution = Column(Integer, default=86400, nullable=False) # seconds
business_hours = Column(JSONB, default=dict) # {"mon": {"start": "09:00", "end": "18:00"}, ...}
fallback_flow_id = Column(UUID(as_uuid=True), ForeignKey("flows.id"), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
agents = relationship("QueueAgent", back_populates="queue")
conversations = relationship("Conversation", back_populates="queue")
class QueueAgent(Base):
__tablename__ = "queue_agents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
is_supervisor = Column(Boolean, default=False, nullable=False)
skills = Column(ARRAY(String), default=list)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
queue = relationship("Queue", back_populates="agents")
user = relationship("User")
Modify services/api-gateway/app/models/__init__.py:
from app.models.user import User, UserRole, UserStatus
from app.models.whatsapp import (
WhatsAppAccount, Contact, Conversation, Message,
AccountStatus, ConversationStatus, MessageDirection, MessageType, MessageStatus
)
from app.models.flow import Flow, FlowSession, TriggerType
from app.models.queue import Queue, QueueAgent, AssignmentMethod
Commit: feat(fase3): add Queue and QueueAgent database models
Task 2: Extender Conversation para SLA y Queue
Files:
- Modify:
services/api-gateway/app/models/whatsapp.py
Code to add after existing Conversation columns (before relationships):
# Add to Conversation class after existing columns:
queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"), nullable=True)
priority = Column(String(20), default="normal", nullable=False) # low, normal, high, urgent
sla_first_response_at = Column(DateTime, nullable=True)
sla_first_response_met = Column(Boolean, nullable=True)
resolved_at = Column(DateTime, nullable=True)
csat_score = Column(Integer, nullable=True) # 1-5
csat_feedback = Column(String(500), nullable=True)
# Add relationship:
queue = relationship("Queue", back_populates="conversations")
Import at top:
from sqlalchemy.orm import relationship
Commit: feat(fase3): extend Conversation model with SLA and queue fields
Task 3: Quick Replies Model
Files:
- Create:
services/api-gateway/app/models/quick_reply.py - Modify:
services/api-gateway/app/models/__init__.py
Code:
# services/api-gateway/app/models/quick_reply.py
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Text, ForeignKey, DateTime
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.core.database import Base
class QuickReply(Base):
__tablename__ = "quick_replies"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
shortcut = Column(String(50), nullable=False, index=True) # e.g., "/saludo"
content = Column(Text, nullable=False)
attachments = Column(JSONB, default=list) # [{type: "image", url: "..."}]
queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"), nullable=True) # null = global
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
queue = relationship("Queue")
creator = relationship("User")
Update __init__.py:
from app.models.quick_reply import QuickReply
Commit: feat(fase3): add QuickReply database model
Task 4: Queue API Schemas
Files:
- Create:
services/api-gateway/app/schemas/queue.py
Code:
# services/api-gateway/app/schemas/queue.py
from pydantic import BaseModel
from typing import Optional, List
from uuid import UUID
from datetime import datetime
from app.models.queue import AssignmentMethod
class QueueCreate(BaseModel):
name: str
description: Optional[str] = None
assignment_method: AssignmentMethod = AssignmentMethod.ROUND_ROBIN
max_per_agent: int = 10
sla_first_response: int = 300
sla_resolution: int = 86400
business_hours: Optional[dict] = None
fallback_flow_id: Optional[UUID] = None
class QueueUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
assignment_method: Optional[AssignmentMethod] = None
max_per_agent: Optional[int] = None
sla_first_response: Optional[int] = None
sla_resolution: Optional[int] = None
business_hours: Optional[dict] = None
fallback_flow_id: Optional[UUID] = None
is_active: Optional[bool] = None
class QueueAgentAdd(BaseModel):
user_id: UUID
is_supervisor: bool = False
skills: List[str] = []
class QueueAgentResponse(BaseModel):
id: UUID
user_id: UUID
user_name: Optional[str] = None
user_email: Optional[str] = None
is_supervisor: bool
skills: List[str]
created_at: datetime
class Config:
from_attributes = True
class QueueResponse(BaseModel):
id: UUID
name: str
description: Optional[str]
assignment_method: AssignmentMethod
max_per_agent: int
sla_first_response: int
sla_resolution: int
business_hours: Optional[dict]
fallback_flow_id: Optional[UUID]
is_active: bool
created_at: datetime
agent_count: Optional[int] = 0
class Config:
from_attributes = True
class QueueDetailResponse(QueueResponse):
agents: List[QueueAgentResponse] = []
class QuickReplyCreate(BaseModel):
shortcut: str
content: str
attachments: List[dict] = []
queue_id: Optional[UUID] = None
class QuickReplyUpdate(BaseModel):
shortcut: Optional[str] = None
content: Optional[str] = None
attachments: Optional[List[dict]] = None
queue_id: Optional[UUID] = None
class QuickReplyResponse(BaseModel):
id: UUID
shortcut: str
content: str
attachments: List[dict]
queue_id: Optional[UUID]
created_by: UUID
created_at: datetime
class Config:
from_attributes = True
Commit: feat(fase3): add Queue and QuickReply API schemas
Task 5: Queue API Routes
Files:
- Create:
services/api-gateway/app/routers/queues.py - Modify:
services/api-gateway/app/main.py
Code:
# services/api-gateway/app/routers/queues.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from uuid import UUID
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, UserRole
from app.models.queue import Queue, QueueAgent
from app.models.quick_reply import QuickReply
from app.schemas.queue import (
QueueCreate, QueueUpdate, QueueResponse, QueueDetailResponse,
QueueAgentAdd, QueueAgentResponse,
QuickReplyCreate, QuickReplyUpdate, QuickReplyResponse
)
router = APIRouter(prefix="/api/queues", tags=["queues"])
def require_admin(current_user: User = Depends(get_current_user)):
if current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Admin required")
return current_user
def require_supervisor(current_user: User = Depends(get_current_user)):
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERVISOR]:
raise HTTPException(status_code=403, detail="Supervisor or Admin required")
return current_user
# Queue CRUD
@router.post("", response_model=QueueResponse)
def create_queue(
request: QueueCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
queue = Queue(**request.model_dump())
db.add(queue)
db.commit()
db.refresh(queue)
return queue
@router.get("", response_model=List[QueueResponse])
def list_queues(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
queues = db.query(Queue).filter(Queue.is_active == True).all()
result = []
for q in queues:
q_dict = QueueResponse.model_validate(q).model_dump()
q_dict["agent_count"] = len(q.agents)
result.append(QueueResponse(**q_dict))
return result
@router.get("/{queue_id}", response_model=QueueDetailResponse)
def get_queue(
queue_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
queue = db.query(Queue).filter(Queue.id == queue_id).first()
if not queue:
raise HTTPException(status_code=404, detail="Queue not found")
agents_data = []
for qa in queue.agents:
agents_data.append(QueueAgentResponse(
id=qa.id,
user_id=qa.user_id,
user_name=qa.user.name if qa.user else None,
user_email=qa.user.email if qa.user else None,
is_supervisor=qa.is_supervisor,
skills=qa.skills or [],
created_at=qa.created_at,
))
return QueueDetailResponse(
**QueueResponse.model_validate(queue).model_dump(),
agents=agents_data,
agent_count=len(agents_data),
)
@router.put("/{queue_id}", response_model=QueueResponse)
def update_queue(
queue_id: UUID,
request: QueueUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
queue = db.query(Queue).filter(Queue.id == queue_id).first()
if not queue:
raise HTTPException(status_code=404, detail="Queue not found")
for key, value in request.model_dump(exclude_unset=True).items():
setattr(queue, key, value)
db.commit()
db.refresh(queue)
return queue
@router.delete("/{queue_id}")
def delete_queue(
queue_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
queue = db.query(Queue).filter(Queue.id == queue_id).first()
if not queue:
raise HTTPException(status_code=404, detail="Queue not found")
queue.is_active = False
db.commit()
return {"success": True}
# Queue Agents
@router.post("/{queue_id}/agents", response_model=QueueAgentResponse)
def add_agent_to_queue(
queue_id: UUID,
request: QueueAgentAdd,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
queue = db.query(Queue).filter(Queue.id == queue_id).first()
if not queue:
raise HTTPException(status_code=404, detail="Queue not found")
existing = db.query(QueueAgent).filter(
QueueAgent.queue_id == queue_id,
QueueAgent.user_id == request.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Agent already in queue")
qa = QueueAgent(
queue_id=queue_id,
user_id=request.user_id,
is_supervisor=request.is_supervisor,
skills=request.skills,
)
db.add(qa)
db.commit()
db.refresh(qa)
return QueueAgentResponse(
id=qa.id,
user_id=qa.user_id,
user_name=qa.user.name if qa.user else None,
user_email=qa.user.email if qa.user else None,
is_supervisor=qa.is_supervisor,
skills=qa.skills or [],
created_at=qa.created_at,
)
@router.delete("/{queue_id}/agents/{user_id}")
def remove_agent_from_queue(
queue_id: UUID,
user_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
qa = db.query(QueueAgent).filter(
QueueAgent.queue_id == queue_id,
QueueAgent.user_id == user_id
).first()
if not qa:
raise HTTPException(status_code=404, detail="Agent not in queue")
db.delete(qa)
db.commit()
return {"success": True}
# Quick Replies
@router.get("/quick-replies", response_model=List[QuickReplyResponse])
def list_quick_replies(
queue_id: UUID = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(QuickReply)
if queue_id:
query = query.filter(
(QuickReply.queue_id == queue_id) | (QuickReply.queue_id == None)
)
else:
query = query.filter(QuickReply.queue_id == None)
return query.all()
@router.post("/quick-replies", response_model=QuickReplyResponse)
def create_quick_reply(
request: QuickReplyCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_supervisor),
):
qr = QuickReply(
**request.model_dump(),
created_by=current_user.id,
)
db.add(qr)
db.commit()
db.refresh(qr)
return qr
@router.put("/quick-replies/{reply_id}", response_model=QuickReplyResponse)
def update_quick_reply(
reply_id: UUID,
request: QuickReplyUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_supervisor),
):
qr = db.query(QuickReply).filter(QuickReply.id == reply_id).first()
if not qr:
raise HTTPException(status_code=404, detail="Quick reply not found")
for key, value in request.model_dump(exclude_unset=True).items():
setattr(qr, key, value)
db.commit()
db.refresh(qr)
return qr
@router.delete("/quick-replies/{reply_id}")
def delete_quick_reply(
reply_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_supervisor),
):
qr = db.query(QuickReply).filter(QuickReply.id == reply_id).first()
if not qr:
raise HTTPException(status_code=404, detail="Quick reply not found")
db.delete(qr)
db.commit()
return {"success": True}
Add to main.py imports and router:
from app.routers import auth, whatsapp, flows, queues
app.include_router(queues.router)
Commit: feat(fase3): add Queue and QuickReply API routes
Task 6: Assignment Service
Files:
- Create:
services/api-gateway/app/services/assignment.py
Code:
# services/api-gateway/app/services/assignment.py
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from uuid import UUID
from datetime import datetime
from app.models.queue import Queue, QueueAgent, AssignmentMethod
from app.models.whatsapp import Conversation, ConversationStatus
from app.models.user import User, UserStatus
class AssignmentService:
def __init__(self, db: Session):
self.db = db
def assign_conversation(self, conversation_id: UUID, queue_id: UUID) -> Optional[User]:
"""Assign a conversation to an agent based on queue settings"""
queue = self.db.query(Queue).filter(Queue.id == queue_id).first()
if not queue or not queue.is_active:
return None
conversation = self.db.query(Conversation).filter(
Conversation.id == conversation_id
).first()
if not conversation:
return None
# Get available agents (online and not at capacity)
available_agents = self._get_available_agents(queue)
if not available_agents:
return None
# Select agent based on assignment method
if queue.assignment_method == AssignmentMethod.ROUND_ROBIN:
agent = self._round_robin_select(queue, available_agents)
elif queue.assignment_method == AssignmentMethod.LEAST_BUSY:
agent = self._least_busy_select(available_agents)
elif queue.assignment_method == AssignmentMethod.SKILL_BASED:
agent = self._skill_based_select(conversation, available_agents)
else:
agent = available_agents[0] if available_agents else None
if agent:
conversation.assigned_to = agent.id
conversation.queue_id = queue_id
conversation.status = ConversationStatus.ACTIVE
self.db.commit()
return agent
def _get_available_agents(self, queue: Queue) -> list:
"""Get agents who are online and have capacity"""
agents = []
for qa in queue.agents:
user = qa.user
if not user or user.status != UserStatus.ONLINE:
continue
# Count active conversations
active_count = self.db.query(Conversation).filter(
Conversation.assigned_to == user.id,
Conversation.status.in_([ConversationStatus.ACTIVE, ConversationStatus.WAITING])
).count()
if active_count < queue.max_per_agent:
agents.append(user)
return agents
def _round_robin_select(self, queue: Queue, agents: list) -> Optional[User]:
"""Select agent with round-robin: least recently assigned"""
if not agents:
return None
# Find agent with oldest last assignment
agent_last_assign = {}
for agent in agents:
last_conv = self.db.query(Conversation).filter(
Conversation.assigned_to == agent.id
).order_by(Conversation.created_at.desc()).first()
agent_last_assign[agent.id] = last_conv.created_at if last_conv else datetime.min
# Sort by last assignment (oldest first)
sorted_agents = sorted(agents, key=lambda a: agent_last_assign[a.id])
return sorted_agents[0] if sorted_agents else None
def _least_busy_select(self, agents: list) -> Optional[User]:
"""Select agent with fewest active conversations"""
if not agents:
return None
min_count = float('inf')
selected = None
for agent in agents:
count = self.db.query(Conversation).filter(
Conversation.assigned_to == agent.id,
Conversation.status.in_([ConversationStatus.ACTIVE, ConversationStatus.WAITING])
).count()
if count < min_count:
min_count = count
selected = agent
return selected
def _skill_based_select(self, conversation: Conversation, agents: list) -> Optional[User]:
"""Select agent based on skills (for now, just return least busy)"""
# TODO: Implement skill matching based on conversation tags/metadata
return self._least_busy_select(agents)
def transfer_to_queue(
self,
conversation_id: UUID,
target_queue_id: UUID,
keep_history: bool = True
) -> bool:
"""Transfer conversation to another queue"""
conversation = self.db.query(Conversation).filter(
Conversation.id == conversation_id
).first()
if not conversation:
return False
conversation.queue_id = target_queue_id
conversation.assigned_to = None
conversation.status = ConversationStatus.WAITING
self.db.commit()
# Try to assign immediately
self.assign_conversation(conversation_id, target_queue_id)
return True
def transfer_to_agent(
self,
conversation_id: UUID,
agent_id: UUID
) -> bool:
"""Transfer conversation directly to a specific agent"""
conversation = self.db.query(Conversation).filter(
Conversation.id == conversation_id
).first()
if not conversation:
return False
agent = self.db.query(User).filter(User.id == agent_id).first()
if not agent:
return False
conversation.assigned_to = agent_id
conversation.status = ConversationStatus.ACTIVE
self.db.commit()
return True
def transfer_to_bot(self, conversation_id: UUID) -> bool:
"""Transfer conversation back to bot"""
conversation = self.db.query(Conversation).filter(
Conversation.id == conversation_id
).first()
if not conversation:
return False
conversation.assigned_to = None
conversation.status = ConversationStatus.BOT
self.db.commit()
return True
def resolve_conversation(
self,
conversation_id: UUID,
csat_score: int = None,
csat_feedback: str = None
) -> bool:
"""Mark conversation as resolved"""
conversation = self.db.query(Conversation).filter(
Conversation.id == conversation_id
).first()
if not conversation:
return False
conversation.status = ConversationStatus.RESOLVED
conversation.resolved_at = datetime.utcnow()
if csat_score:
conversation.csat_score = csat_score
if csat_feedback:
conversation.csat_feedback = csat_feedback
self.db.commit()
return True
Commit: feat(fase3): add AssignmentService for queue management
Task 7: Conversation Actions API
Files:
- Modify:
services/api-gateway/app/routers/whatsapp.py - Modify:
services/api-gateway/app/schemas/whatsapp.py
Add to schemas/whatsapp.py:
# Add these new schemas
class TransferToQueueRequest(BaseModel):
queue_id: UUID
class TransferToAgentRequest(BaseModel):
agent_id: UUID
class ResolveConversationRequest(BaseModel):
csat_score: Optional[int] = None
csat_feedback: Optional[str] = None
class InternalNoteRequest(BaseModel):
content: str
Add to routers/whatsapp.py (after existing routes):
from app.services.assignment import AssignmentService
from app.schemas.whatsapp import (
# ... existing imports ...
TransferToQueueRequest, TransferToAgentRequest,
ResolveConversationRequest, InternalNoteRequest
)
@router.post("/conversations/{conversation_id}/transfer-to-queue")
def transfer_to_queue(
conversation_id: UUID,
request: TransferToQueueRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = AssignmentService(db)
if service.transfer_to_queue(conversation_id, request.queue_id):
return {"success": True}
raise HTTPException(status_code=400, detail="Transfer failed")
@router.post("/conversations/{conversation_id}/transfer-to-agent")
def transfer_to_agent(
conversation_id: UUID,
request: TransferToAgentRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = AssignmentService(db)
if service.transfer_to_agent(conversation_id, request.agent_id):
return {"success": True}
raise HTTPException(status_code=400, detail="Transfer failed")
@router.post("/conversations/{conversation_id}/transfer-to-bot")
def transfer_to_bot(
conversation_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = AssignmentService(db)
if service.transfer_to_bot(conversation_id):
return {"success": True}
raise HTTPException(status_code=400, detail="Transfer failed")
@router.post("/conversations/{conversation_id}/resolve")
def resolve_conversation(
conversation_id: UUID,
request: ResolveConversationRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = AssignmentService(db)
if service.resolve_conversation(
conversation_id,
request.csat_score,
request.csat_feedback
):
return {"success": True}
raise HTTPException(status_code=400, detail="Failed to resolve")
@router.post("/conversations/{conversation_id}/notes")
def add_internal_note(
conversation_id: UUID,
request: InternalNoteRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conversation = db.query(Conversation).filter(
Conversation.id == conversation_id
).first()
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
message = Message(
conversation_id=conversation.id,
direction=MessageDirection.OUTBOUND,
type=MessageType.TEXT,
content=request.content,
sent_by=current_user.id,
is_internal_note=True,
status=MessageStatus.DELIVERED,
)
db.add(message)
db.commit()
db.refresh(message)
return {"success": True, "message_id": str(message.id)}
Commit: feat(fase3): add conversation transfer and note API routes
Task 8: Agent Status API
Files:
- Modify:
services/api-gateway/app/routers/auth.py - Add to:
services/api-gateway/app/schemas/auth.py
Add to schemas/auth.py:
from app.models.user import UserStatus
class UpdateStatusRequest(BaseModel):
status: UserStatus
Add to routers/auth.py:
from app.schemas.auth import UpdateStatusRequest
from app.models.user import UserStatus
@router.put("/me/status")
def update_my_status(
request: UpdateStatusRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
current_user.status = request.status
db.commit()
return {"success": True, "status": request.status.value}
@router.get("/agents")
def list_agents(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all agents with their status and conversation counts"""
from app.models.whatsapp import Conversation, ConversationStatus
agents = db.query(User).filter(User.is_active == True).all()
result = []
for agent in agents:
active_count = db.query(Conversation).filter(
Conversation.assigned_to == agent.id,
Conversation.status.in_([ConversationStatus.ACTIVE, ConversationStatus.WAITING])
).count()
result.append({
"id": str(agent.id),
"name": agent.name,
"email": agent.email,
"role": agent.role.value,
"status": agent.status.value,
"active_conversations": active_count,
})
return result
Commit: feat(fase3): add agent status update and list endpoints
Task 9: Supervisor Dashboard API
Files:
- Create:
services/api-gateway/app/routers/supervisor.py - Modify:
services/api-gateway/app/main.py
Code:
# services/api-gateway/app/routers/supervisor.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List
from datetime import datetime, timedelta
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, UserRole, UserStatus
from app.models.whatsapp import Conversation, Message, ConversationStatus
from app.models.queue import Queue
router = APIRouter(prefix="/api/supervisor", tags=["supervisor"])
def require_supervisor(current_user: User = Depends(get_current_user)):
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERVISOR]:
raise HTTPException(status_code=403, detail="Supervisor or Admin required")
return current_user
@router.get("/dashboard")
def get_dashboard(
db: Session = Depends(get_db),
current_user: User = Depends(require_supervisor),
):
"""Get supervisor dashboard data"""
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Conversations by status
status_counts = {}
for status in ConversationStatus:
count = db.query(Conversation).filter(
Conversation.status == status
).count()
status_counts[status.value] = count
# Waiting conversations (queue)
waiting_conversations = db.query(Conversation).filter(
Conversation.status == ConversationStatus.WAITING
).count()
# Active agents
active_agents = db.query(User).filter(
User.status == UserStatus.ONLINE,
User.is_active == True
).count()
# Total agents
total_agents = db.query(User).filter(
User.role.in_([UserRole.AGENT, UserRole.SUPERVISOR]),
User.is_active == True
).count()
# Today's stats
resolved_today = db.query(Conversation).filter(
Conversation.resolved_at >= today_start
).count()
messages_today = db.query(Message).filter(
Message.created_at >= today_start
).count()
# Average response time (simplified)
avg_response = None # TODO: Calculate from SLA data
# CSAT average
csat_result = db.query(func.avg(Conversation.csat_score)).filter(
Conversation.csat_score != None,
Conversation.resolved_at >= today_start
).scalar()
avg_csat = round(float(csat_result), 2) if csat_result else None
return {
"conversations": {
"by_status": status_counts,
"waiting": waiting_conversations,
"resolved_today": resolved_today,
},
"agents": {
"online": active_agents,
"total": total_agents,
},
"messages_today": messages_today,
"avg_csat": avg_csat,
}
@router.get("/agents")
def get_agents_status(
db: Session = Depends(get_db),
current_user: User = Depends(require_supervisor),
):
"""Get detailed agent status for supervisor view"""
agents = db.query(User).filter(
User.role.in_([UserRole.AGENT, UserRole.SUPERVISOR]),
User.is_active == True
).all()
result = []
for agent in agents:
# Active conversations
active = db.query(Conversation).filter(
Conversation.assigned_to == agent.id,
Conversation.status == ConversationStatus.ACTIVE
).count()
# Waiting conversations
waiting = db.query(Conversation).filter(
Conversation.assigned_to == agent.id,
Conversation.status == ConversationStatus.WAITING
).count()
# Resolved today
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
resolved_today = db.query(Conversation).filter(
Conversation.assigned_to == agent.id,
Conversation.resolved_at >= today_start
).count()
result.append({
"id": str(agent.id),
"name": agent.name,
"email": agent.email,
"role": agent.role.value,
"status": agent.status.value,
"active_conversations": active,
"waiting_conversations": waiting,
"resolved_today": resolved_today,
})
return result
@router.get("/queues")
def get_queues_status(
db: Session = Depends(get_db),
current_user: User = Depends(require_supervisor),
):
"""Get queue status for supervisor view"""
queues = db.query(Queue).filter(Queue.is_active == True).all()
result = []
for queue in queues:
waiting = db.query(Conversation).filter(
Conversation.queue_id == queue.id,
Conversation.status == ConversationStatus.WAITING
).count()
active = db.query(Conversation).filter(
Conversation.queue_id == queue.id,
Conversation.status == ConversationStatus.ACTIVE
).count()
online_agents = sum(
1 for qa in queue.agents
if qa.user and qa.user.status == UserStatus.ONLINE
)
result.append({
"id": str(queue.id),
"name": queue.name,
"waiting_conversations": waiting,
"active_conversations": active,
"online_agents": online_agents,
"total_agents": len(queue.agents),
"sla_first_response": queue.sla_first_response,
})
return result
@router.get("/conversations/critical")
def get_critical_conversations(
db: Session = Depends(get_db),
current_user: User = Depends(require_supervisor),
):
"""Get conversations that need attention (SLA breach, long wait, etc.)"""
now = datetime.utcnow()
# Conversations waiting more than 5 minutes
long_wait = db.query(Conversation).filter(
Conversation.status == ConversationStatus.WAITING,
Conversation.last_message_at < now - timedelta(minutes=5)
).all()
# High priority conversations
high_priority = db.query(Conversation).filter(
Conversation.priority.in_(["high", "urgent"]),
Conversation.status.in_([ConversationStatus.WAITING, ConversationStatus.ACTIVE])
).all()
result = []
seen_ids = set()
for conv in long_wait + high_priority:
if conv.id in seen_ids:
continue
seen_ids.add(conv.id)
result.append({
"id": str(conv.id),
"contact_name": conv.contact.name if conv.contact else "Unknown",
"contact_phone": conv.contact.phone_number if conv.contact else "",
"status": conv.status.value,
"priority": conv.priority,
"assigned_to": str(conv.assigned_to) if conv.assigned_to else None,
"last_message_at": conv.last_message_at.isoformat() if conv.last_message_at else None,
"reason": "long_wait" if conv in long_wait else "high_priority",
})
return result
Add to main.py:
from app.routers import auth, whatsapp, flows, queues, supervisor
app.include_router(supervisor.router)
Commit: feat(fase3): add supervisor dashboard API
Task 10: Frontend - Queue Management Page
Files:
- Create:
frontend/src/pages/Queues.tsx
Code:
// frontend/src/pages/Queues.tsx
import { useState } from 'react';
import {
Card,
Table,
Button,
Modal,
Form,
Input,
InputNumber,
Select,
Space,
Tag,
Popconfirm,
message,
Typography,
Tabs,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
const { Title } = Typography;
interface Queue {
id: string;
name: string;
description: string | null;
assignment_method: string;
max_per_agent: number;
sla_first_response: number;
sla_resolution: number;
is_active: boolean;
agent_count: number;
}
interface Agent {
id: string;
name: string;
email: string;
role: string;
status: string;
}
export default function Queues() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAgentsModalOpen, setIsAgentsModalOpen] = useState(false);
const [selectedQueue, setSelectedQueue] = useState<Queue | null>(null);
const [form] = Form.useForm();
const queryClient = useQueryClient();
const { data: queues, isLoading } = useQuery({
queryKey: ['queues'],
queryFn: () => apiClient.get<Queue[]>('/api/queues'),
});
const { data: agents } = useQuery({
queryKey: ['agents'],
queryFn: () => apiClient.get<Agent[]>('/api/auth/agents'),
});
const createMutation = useMutation({
mutationFn: (data: Partial<Queue>) => apiClient.post('/api/queues', data),
onSuccess: () => {
message.success('Cola creada');
queryClient.invalidateQueries({ queryKey: ['queues'] });
setIsModalOpen(false);
form.resetFields();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Queue> }) =>
apiClient.put(`/api/queues/${id}`, data),
onSuccess: () => {
message.success('Cola actualizada');
queryClient.invalidateQueries({ queryKey: ['queues'] });
setIsModalOpen(false);
setSelectedQueue(null);
form.resetFields();
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/api/queues/${id}`),
onSuccess: () => {
message.success('Cola eliminada');
queryClient.invalidateQueries({ queryKey: ['queues'] });
},
});
const handleSubmit = (values: Partial<Queue>) => {
if (selectedQueue) {
updateMutation.mutate({ id: selectedQueue.id, data: values });
} else {
createMutation.mutate(values);
}
};
const handleEdit = (queue: Queue) => {
setSelectedQueue(queue);
form.setFieldsValue(queue);
setIsModalOpen(true);
};
const columns = [
{
title: 'Nombre',
dataIndex: 'name',
key: 'name',
},
{
title: 'Método Asignación',
dataIndex: 'assignment_method',
key: 'assignment_method',
render: (method: string) => {
const labels: Record<string, string> = {
round_robin: 'Round Robin',
least_busy: 'Menos Ocupado',
skill_based: 'Por Habilidades',
};
return labels[method] || method;
},
},
{
title: 'Agentes',
dataIndex: 'agent_count',
key: 'agent_count',
render: (count: number) => <Tag icon={<TeamOutlined />}>{count}</Tag>,
},
{
title: 'Max/Agente',
dataIndex: 'max_per_agent',
key: 'max_per_agent',
},
{
title: 'SLA Respuesta',
dataIndex: 'sla_first_response',
key: 'sla_first_response',
render: (seconds: number) => `${Math.round(seconds / 60)} min`,
},
{
title: 'Acciones',
key: 'actions',
render: (_: unknown, record: Queue) => (
<Space>
<Button
size="small"
icon={<TeamOutlined />}
onClick={() => {
setSelectedQueue(record);
setIsAgentsModalOpen(true);
}}
>
Agentes
</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Popconfirm
title="¿Eliminar cola?"
onConfirm={() => deleteMutation.mutate(record.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={4}>Colas de Atención</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setSelectedQueue(null);
form.resetFields();
setIsModalOpen(true);
}}
>
Nueva Cola
</Button>
</div>
<Card>
<Table
dataSource={queues}
columns={columns}
rowKey="id"
loading={isLoading}
pagination={false}
/>
</Card>
<Modal
title={selectedQueue ? 'Editar Cola' : 'Nueva Cola'}
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
setSelectedQueue(null);
form.resetFields();
}}
footer={null}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="name" label="Nombre" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Descripción">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="assignment_method" label="Método de Asignación" initialValue="round_robin">
<Select>
<Select.Option value="round_robin">Round Robin</Select.Option>
<Select.Option value="least_busy">Menos Ocupado</Select.Option>
<Select.Option value="skill_based">Por Habilidades</Select.Option>
</Select>
</Form.Item>
<Form.Item name="max_per_agent" label="Máximo por Agente" initialValue={10}>
<InputNumber min={1} max={50} />
</Form.Item>
<Form.Item name="sla_first_response" label="SLA Primera Respuesta (segundos)" initialValue={300}>
<InputNumber min={60} step={60} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={createMutation.isPending || updateMutation.isPending}>
{selectedQueue ? 'Actualizar' : 'Crear'}
</Button>
<Button onClick={() => setIsModalOpen(false)}>Cancelar</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={`Agentes - ${selectedQueue?.name}`}
open={isAgentsModalOpen}
onCancel={() => setIsAgentsModalOpen(false)}
footer={null}
width={600}
>
<p>Gestión de agentes en cola (próximamente)</p>
</Modal>
</div>
);
}
Commit: feat(fase3): add Queue management frontend page
Task 11: Frontend - Enhanced Inbox
Files:
- Modify:
frontend/src/pages/Inbox.tsx
Replace entire file with enhanced version:
// frontend/src/pages/Inbox.tsx
import { useState } from 'react';
import {
Card,
List,
Avatar,
Typography,
Input,
Button,
Tag,
Empty,
Spin,
Space,
Badge,
Dropdown,
Divider,
Select,
Tooltip,
Modal,
message,
} from 'antd';
import {
SendOutlined,
UserOutlined,
MoreOutlined,
SwapOutlined,
RobotOutlined,
CheckCircleOutlined,
MessageOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/es';
dayjs.extend(relativeTime);
dayjs.locale('es');
const { Text } = Typography;
const { TextArea } = Input;
interface Contact {
id: string;
phone_number: string;
name: string | null;
}
interface Message {
id: string;
direction: 'inbound' | 'outbound';
type: string;
content: string | null;
created_at: string;
is_internal_note: boolean;
sent_by: string | null;
}
interface Conversation {
id: string;
contact: Contact;
status: string;
priority: string;
assigned_to: string | null;
queue_id: string | null;
last_message_at: string | null;
messages?: Message[];
}
interface Queue {
id: string;
name: string;
}
interface Agent {
id: string;
name: string;
status: string;
}
export default function Inbox() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [messageText, setMessageText] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [isNoteMode, setIsNoteMode] = useState(false);
const [transferModalOpen, setTransferModalOpen] = useState(false);
const [transferType, setTransferType] = useState<'queue' | 'agent'>('queue');
const queryClient = useQueryClient();
const { data: conversations, isLoading } = useQuery({
queryKey: ['conversations', statusFilter],
queryFn: async () => {
const url = statusFilter
? `/api/whatsapp/conversations?status=${statusFilter}`
: '/api/whatsapp/conversations';
return apiClient.get<Conversation[]>(url);
},
refetchInterval: 3000,
});
const { data: selectedConversation } = useQuery({
queryKey: ['conversation', selectedId],
queryFn: async () => {
if (!selectedId) return null;
return apiClient.get<Conversation>(`/api/whatsapp/conversations/${selectedId}`);
},
enabled: !!selectedId,
refetchInterval: 2000,
});
const { data: queues } = useQuery({
queryKey: ['queues'],
queryFn: () => apiClient.get<Queue[]>('/api/queues'),
});
const { data: agents } = useQuery({
queryKey: ['agents'],
queryFn: () => apiClient.get<Agent[]>('/api/auth/agents'),
});
const { data: quickReplies } = useQuery({
queryKey: ['quick-replies'],
queryFn: () => apiClient.get<{ shortcut: string; content: string }[]>('/api/queues/quick-replies'),
});
const sendMutation = useMutation({
mutationFn: async (data: { conversationId: string; content: string }) => {
await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/messages`, {
type: 'text',
content: data.content,
});
},
onSuccess: () => {
setMessageText('');
queryClient.invalidateQueries({ queryKey: ['conversation', selectedId] });
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
const noteMutation = useMutation({
mutationFn: async (data: { conversationId: string; content: string }) => {
await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/notes`, {
content: data.content,
});
},
onSuccess: () => {
setMessageText('');
setIsNoteMode(false);
message.success('Nota agregada');
queryClient.invalidateQueries({ queryKey: ['conversation', selectedId] });
},
});
const transferQueueMutation = useMutation({
mutationFn: async (data: { conversationId: string; queueId: string }) => {
await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/transfer-to-queue`, {
queue_id: data.queueId,
});
},
onSuccess: () => {
message.success('Transferido a cola');
setTransferModalOpen(false);
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
const transferAgentMutation = useMutation({
mutationFn: async (data: { conversationId: string; agentId: string }) => {
await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/transfer-to-agent`, {
agent_id: data.agentId,
});
},
onSuccess: () => {
message.success('Transferido a agente');
setTransferModalOpen(false);
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
const transferBotMutation = useMutation({
mutationFn: async (conversationId: string) => {
await apiClient.post(`/api/whatsapp/conversations/${conversationId}/transfer-to-bot`, {});
},
onSuccess: () => {
message.success('Transferido a bot');
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
const resolveMutation = useMutation({
mutationFn: async (conversationId: string) => {
await apiClient.post(`/api/whatsapp/conversations/${conversationId}/resolve`, {});
},
onSuccess: () => {
message.success('Conversación resuelta');
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
const handleSend = () => {
if (!messageText.trim() || !selectedId) return;
if (isNoteMode) {
noteMutation.mutate({ conversationId: selectedId, content: messageText });
} else {
sendMutation.mutate({ conversationId: selectedId, content: messageText });
}
};
const handleQuickReply = (content: string) => {
setMessageText(content);
};
const statusColors: Record<string, string> = {
bot: 'blue',
waiting: 'orange',
active: 'green',
resolved: 'default',
};
const priorityColors: Record<string, string> = {
low: 'default',
normal: 'blue',
high: 'orange',
urgent: 'red',
};
const actionMenuItems = [
{
key: 'transfer-queue',
icon: <SwapOutlined />,
label: 'Transferir a cola',
onClick: () => {
setTransferType('queue');
setTransferModalOpen(true);
},
},
{
key: 'transfer-agent',
icon: <UserOutlined />,
label: 'Transferir a agente',
onClick: () => {
setTransferType('agent');
setTransferModalOpen(true);
},
},
{
key: 'transfer-bot',
icon: <RobotOutlined />,
label: 'Transferir a bot',
onClick: () => selectedId && transferBotMutation.mutate(selectedId),
},
{ type: 'divider' as const },
{
key: 'resolve',
icon: <CheckCircleOutlined />,
label: 'Resolver conversación',
onClick: () => selectedId && resolveMutation.mutate(selectedId),
},
];
return (
<div style={{ display: 'flex', height: 'calc(100vh - 180px)', gap: 16 }}>
{/* Lista de conversaciones */}
<Card
style={{ width: 380, overflow: 'auto' }}
styles={{ body: { padding: 0 } }}
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Conversaciones</span>
<Select
placeholder="Filtrar"
allowClear
size="small"
style={{ width: 120 }}
value={statusFilter}
onChange={setStatusFilter}
>
<Select.Option value="bot">Bot</Select.Option>
<Select.Option value="waiting">En espera</Select.Option>
<Select.Option value="active">Activas</Select.Option>
<Select.Option value="resolved">Resueltas</Select.Option>
</Select>
</div>
}
>
{isLoading ? (
<div style={{ padding: 40, textAlign: 'center' }}>
<Spin />
</div>
) : conversations?.length === 0 ? (
<Empty description="Sin conversaciones" style={{ padding: 40 }} />
) : (
<List
dataSource={conversations}
renderItem={(conv) => (
<List.Item
onClick={() => setSelectedId(conv.id)}
style={{
padding: '12px 16px',
cursor: 'pointer',
background: selectedId === conv.id ? '#f5f5f5' : 'transparent',
}}
>
<List.Item.Meta
avatar={
<Badge dot={conv.status !== 'resolved'} color={statusColors[conv.status]}>
<Avatar icon={<UserOutlined />} />
</Badge>
}
title={
<Space>
<Text strong>{conv.contact.name || conv.contact.phone_number}</Text>
<Tag color={statusColors[conv.status]} style={{ fontSize: 10 }}>
{conv.status}
</Tag>
{conv.priority !== 'normal' && (
<Tag color={priorityColors[conv.priority]} style={{ fontSize: 10 }}>
{conv.priority}
</Tag>
)}
</Space>
}
description={
<Text type="secondary" style={{ fontSize: 12 }}>
{conv.last_message_at ? dayjs(conv.last_message_at).fromNow() : 'Sin mensajes'}
</Text>
}
/>
</List.Item>
)}
/>
)}
</Card>
{/* Chat */}
<Card
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0 } }}
>
{selectedConversation ? (
<>
{/* Header */}
<div
style={{
padding: 16,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<Text strong style={{ fontSize: 16 }}>
{selectedConversation.contact.name || selectedConversation.contact.phone_number}
</Text>
<br />
<Text type="secondary">{selectedConversation.contact.phone_number}</Text>
</div>
<Space>
<Tag color={statusColors[selectedConversation.status]}>
{selectedConversation.status}
</Tag>
<Dropdown menu={{ items: actionMenuItems }} trigger={['click']}>
<Button icon={<MoreOutlined />} />
</Dropdown>
</Space>
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{selectedConversation.messages?.map((msg) => (
<div
key={msg.id}
style={{
alignSelf: msg.direction === 'outbound' ? 'flex-end' : 'flex-start',
maxWidth: '70%',
}}
>
<div
style={{
padding: '8px 12px',
borderRadius: 8,
background: msg.is_internal_note
? '#fff7e6'
: msg.direction === 'outbound'
? '#25D366'
: '#f0f0f0',
color: msg.is_internal_note ? '#d46b08' : msg.direction === 'outbound' ? 'white' : 'inherit',
border: msg.is_internal_note ? '1px dashed #ffc069' : 'none',
}}
>
{msg.is_internal_note && (
<Text type="secondary" style={{ fontSize: 10, display: 'block', marginBottom: 4 }}>
<FileTextOutlined /> Nota interna
</Text>
)}
<Text style={{ color: 'inherit' }}>{msg.content}</Text>
</div>
<Text
type="secondary"
style={{
fontSize: 10,
display: 'block',
textAlign: msg.direction === 'outbound' ? 'right' : 'left',
marginTop: 4,
}}
>
{dayjs(msg.created_at).format('HH:mm')}
</Text>
</div>
))}
</div>
{/* Quick Replies */}
{quickReplies && quickReplies.length > 0 && (
<div style={{ padding: '8px 16px', borderTop: '1px solid #f0f0f0' }}>
<Space wrap size={4}>
{quickReplies.slice(0, 5).map((qr) => (
<Tooltip key={qr.shortcut} title={qr.content}>
<Tag
style={{ cursor: 'pointer' }}
onClick={() => handleQuickReply(qr.content)}
>
{qr.shortcut}
</Tag>
</Tooltip>
))}
</Space>
</div>
)}
{/* Input */}
<div
style={{
padding: 16,
borderTop: '1px solid #f0f0f0',
}}
>
<div style={{ marginBottom: 8 }}>
<Space>
<Button
size="small"
type={isNoteMode ? 'default' : 'primary'}
icon={<MessageOutlined />}
onClick={() => setIsNoteMode(false)}
>
Mensaje
</Button>
<Button
size="small"
type={isNoteMode ? 'primary' : 'default'}
icon={<FileTextOutlined />}
onClick={() => setIsNoteMode(true)}
style={isNoteMode ? { background: '#d46b08', borderColor: '#d46b08' } : {}}
>
Nota interna
</Button>
</Space>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Input
placeholder={isNoteMode ? 'Escribe una nota interna...' : 'Escribe un mensaje...'}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onPressEnter={handleSend}
style={isNoteMode ? { borderColor: '#ffc069' } : {}}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sendMutation.isPending || noteMutation.isPending}
style={isNoteMode ? { background: '#d46b08', borderColor: '#d46b08' } : { background: '#25D366', borderColor: '#25D366' }}
/>
</div>
</div>
</>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="Selecciona una conversación" />
</div>
)}
</Card>
{/* Transfer Modal */}
<Modal
title={transferType === 'queue' ? 'Transferir a Cola' : 'Transferir a Agente'}
open={transferModalOpen}
onCancel={() => setTransferModalOpen(false)}
footer={null}
>
{transferType === 'queue' ? (
<Select
placeholder="Seleccionar cola"
style={{ width: '100%' }}
onChange={(value) => {
if (selectedId) {
transferQueueMutation.mutate({ conversationId: selectedId, queueId: value });
}
}}
>
{queues?.map((q) => (
<Select.Option key={q.id} value={q.id}>
{q.name}
</Select.Option>
))}
</Select>
) : (
<Select
placeholder="Seleccionar agente"
style={{ width: '100%' }}
onChange={(value) => {
if (selectedId) {
transferAgentMutation.mutate({ conversationId: selectedId, agentId: value });
}
}}
>
{agents
?.filter((a) => a.status === 'online')
.map((a) => (
<Select.Option key={a.id} value={a.id}>
{a.name} ({a.status})
</Select.Option>
))}
</Select>
)}
</Modal>
</div>
);
}
Commit: feat(fase3): enhance Inbox with transfers, notes, and quick replies
Task 12: Frontend - Supervisor Dashboard
Files:
- Create:
frontend/src/pages/SupervisorDashboard.tsx
Code:
// frontend/src/pages/SupervisorDashboard.tsx
import { useState } from 'react';
import {
Card,
Row,
Col,
Statistic,
Table,
Tag,
Typography,
Badge,
Button,
Space,
Tabs,
List,
Avatar,
Alert,
} from 'antd';
import {
TeamOutlined,
MessageOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
ReloadOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import dayjs from 'dayjs';
const { Title, Text } = Typography;
interface DashboardData {
conversations: {
by_status: Record<string, number>;
waiting: number;
resolved_today: number;
};
agents: {
online: number;
total: number;
};
messages_today: number;
avg_csat: number | null;
}
interface AgentStatus {
id: string;
name: string;
email: string;
role: string;
status: string;
active_conversations: number;
waiting_conversations: number;
resolved_today: number;
}
interface QueueStatus {
id: string;
name: string;
waiting_conversations: number;
active_conversations: number;
online_agents: number;
total_agents: number;
}
interface CriticalConversation {
id: string;
contact_name: string;
contact_phone: string;
status: string;
priority: string;
last_message_at: string | null;
reason: string;
}
export default function SupervisorDashboard() {
const { data: dashboard, isLoading: dashboardLoading, refetch: refetchDashboard } = useQuery({
queryKey: ['supervisor-dashboard'],
queryFn: () => apiClient.get<DashboardData>('/api/supervisor/dashboard'),
refetchInterval: 10000,
});
const { data: agents, refetch: refetchAgents } = useQuery({
queryKey: ['supervisor-agents'],
queryFn: () => apiClient.get<AgentStatus[]>('/api/supervisor/agents'),
refetchInterval: 10000,
});
const { data: queues, refetch: refetchQueues } = useQuery({
queryKey: ['supervisor-queues'],
queryFn: () => apiClient.get<QueueStatus[]>('/api/supervisor/queues'),
refetchInterval: 10000,
});
const { data: criticalConversations } = useQuery({
queryKey: ['critical-conversations'],
queryFn: () => apiClient.get<CriticalConversation[]>('/api/supervisor/conversations/critical'),
refetchInterval: 15000,
});
const handleRefresh = () => {
refetchDashboard();
refetchAgents();
refetchQueues();
};
const statusColors: Record<string, string> = {
online: 'green',
offline: 'default',
away: 'orange',
busy: 'red',
};
const agentColumns = [
{
title: 'Agente',
dataIndex: 'name',
key: 'name',
render: (name: string, record: AgentStatus) => (
<Space>
<Badge status={record.status === 'online' ? 'success' : 'default'} />
<Text>{name}</Text>
</Space>
),
},
{
title: 'Estado',
dataIndex: 'status',
key: 'status',
render: (status: string) => <Tag color={statusColors[status]}>{status}</Tag>,
},
{
title: 'Activas',
dataIndex: 'active_conversations',
key: 'active',
render: (count: number) => <Badge count={count} showZero color={count > 0 ? 'blue' : 'default'} />,
},
{
title: 'En Espera',
dataIndex: 'waiting_conversations',
key: 'waiting',
render: (count: number) => (
<Badge count={count} showZero color={count > 0 ? 'orange' : 'default'} />
),
},
{
title: 'Resueltas Hoy',
dataIndex: 'resolved_today',
key: 'resolved',
},
];
const queueColumns = [
{
title: 'Cola',
dataIndex: 'name',
key: 'name',
},
{
title: 'En Espera',
dataIndex: 'waiting_conversations',
key: 'waiting',
render: (count: number) => (
<Badge count={count} showZero color={count > 0 ? 'orange' : 'default'} />
),
},
{
title: 'Activas',
dataIndex: 'active_conversations',
key: 'active',
render: (count: number) => <Badge count={count} showZero color={count > 0 ? 'blue' : 'default'} />,
},
{
title: 'Agentes Online',
key: 'agents',
render: (_: unknown, record: QueueStatus) => (
<Text>
{record.online_agents}/{record.total_agents}
</Text>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={4}>Panel de Supervisor</Title>
<Button icon={<ReloadOutlined />} onClick={handleRefresh}>
Actualizar
</Button>
</div>
{/* Critical Alerts */}
{criticalConversations && criticalConversations.length > 0 && (
<Alert
message={`${criticalConversations.length} conversaciones requieren atención`}
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
style={{ marginBottom: 16 }}
action={
<Button size="small" type="link">
Ver detalles
</Button>
}
/>
)}
{/* Stats */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic
title="Agentes Online"
value={dashboard?.agents.online || 0}
suffix={`/ ${dashboard?.agents.total || 0}`}
prefix={<TeamOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="En Espera"
value={dashboard?.conversations.waiting || 0}
prefix={<ClockCircleOutlined />}
valueStyle={{ color: dashboard?.conversations.waiting ? '#faad14' : undefined }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Resueltas Hoy"
value={dashboard?.conversations.resolved_today || 0}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Mensajes Hoy"
value={dashboard?.messages_today || 0}
prefix={<MessageOutlined />}
/>
</Card>
</Col>
</Row>
{/* Status by type */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card size="small">
<Statistic
title="Bot"
value={dashboard?.conversations.by_status?.bot || 0}
valueStyle={{ fontSize: 20, color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="Esperando"
value={dashboard?.conversations.by_status?.waiting || 0}
valueStyle={{ fontSize: 20, color: '#faad14' }}
/>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="Activas"
value={dashboard?.conversations.by_status?.active || 0}
valueStyle={{ fontSize: 20, color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="CSAT Promedio"
value={dashboard?.avg_csat || '-'}
suffix={dashboard?.avg_csat ? '/ 5' : ''}
valueStyle={{ fontSize: 20 }}
/>
</Card>
</Col>
</Row>
{/* Tables */}
<Tabs
items={[
{
key: 'agents',
label: 'Estado de Agentes',
children: (
<Card>
<Table
dataSource={agents}
columns={agentColumns}
rowKey="id"
pagination={false}
size="small"
/>
</Card>
),
},
{
key: 'queues',
label: 'Estado de Colas',
children: (
<Card>
<Table
dataSource={queues}
columns={queueColumns}
rowKey="id"
pagination={false}
size="small"
/>
</Card>
),
},
{
key: 'critical',
label: (
<span>
Críticas{' '}
{criticalConversations && criticalConversations.length > 0 && (
<Badge count={criticalConversations.length} />
)}
</span>
),
children: (
<Card>
{criticalConversations && criticalConversations.length > 0 ? (
<List
dataSource={criticalConversations}
renderItem={(conv) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar icon={<UserOutlined />} />}
title={conv.contact_name || conv.contact_phone}
description={
<Space>
<Tag color={conv.reason === 'long_wait' ? 'orange' : 'red'}>
{conv.reason === 'long_wait' ? 'Espera prolongada' : 'Alta prioridad'}
</Tag>
<Text type="secondary">
{conv.last_message_at
? dayjs(conv.last_message_at).fromNow()
: 'Sin mensajes'}
</Text>
</Space>
}
/>
</List.Item>
)}
/>
) : (
<Text type="secondary">No hay conversaciones críticas</Text>
)}
</Card>
),
},
]}
/>
</div>
);
}
Commit: feat(fase3): add Supervisor Dashboard frontend
Task 13: Update MainLayout with New Routes
Files:
- Modify:
frontend/src/layouts/MainLayout.tsx
Add imports:
import {
// ... existing imports ...
TeamOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import Queues from '../pages/Queues';
import SupervisorDashboard from '../pages/SupervisorDashboard';
Add menu items (after Flujos, before Configuración):
{
key: '/queues',
icon: <TeamOutlined />,
label: 'Colas',
},
{
key: '/supervisor',
icon: <BarChartOutlined />,
label: 'Supervisor',
},
Add routes (inside Routes component):
<Route path="/queues" element={<Queues />} />
<Route path="/supervisor" element={<SupervisorDashboard />} />
Commit: feat(fase3): add Queues and Supervisor routes to MainLayout
Task 14: Final Integration - Update Docker Compose
Files:
- No changes needed to docker-compose.yml for this phase
Final Commit: feat(fase3): complete Inbox Avanzado + Multi-agente phase
Summary
This plan implements:
- Database Models: Queue, QueueAgent, QuickReply, extended Conversation
- Backend Services: AssignmentService for intelligent queue distribution
- API Endpoints:
- Queue CRUD and agent management
- Quick replies management
- Conversation transfers (queue, agent, bot)
- Internal notes
- Agent status updates
- Supervisor dashboard data
- 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