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>
2450 lines
71 KiB
Markdown
2450 lines
71 KiB
Markdown
# 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<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:**
|
|
|
|
```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<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:**
|
|
|
|
```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<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:**
|
|
```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: <TeamOutlined />,
|
|
label: 'Colas',
|
|
},
|
|
{
|
|
key: '/supervisor',
|
|
icon: <BarChartOutlined />,
|
|
label: 'Supervisor',
|
|
},
|
|
```
|
|
|
|
**Add routes (inside Routes component):**
|
|
```tsx
|
|
<Route path="/queues" element={<Queues />} />
|
|
<Route path="/supervisor" element={<SupervisorDashboard />} />
|
|
```
|
|
|
|
**Commit:** `feat(fase3): add Queues and Supervisor routes to MainLayout`
|
|
|
|
---
|
|
|
|
## Task 14: Final Integration - Update Docker Compose
|
|
|
|
**Files:**
|
|
- No changes needed to docker-compose.yml for this phase
|
|
|
|
**Final Commit:** `feat(fase3): complete Inbox Avanzado + Multi-agente phase`
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
This plan implements:
|
|
|
|
1. **Database Models**: Queue, QueueAgent, QuickReply, extended Conversation
|
|
2. **Backend Services**: AssignmentService for intelligent queue distribution
|
|
3. **API Endpoints**:
|
|
- Queue CRUD and agent management
|
|
- Quick replies management
|
|
- Conversation transfers (queue, agent, bot)
|
|
- Internal notes
|
|
- Agent status updates
|
|
- Supervisor dashboard data
|
|
4. **Frontend Pages**:
|
|
- Queue management
|
|
- Enhanced Inbox with transfers and notes
|
|
- Supervisor dashboard with real-time stats
|
|
|
|
**Key Features:**
|
|
- Round-robin, least-busy, and skill-based assignment
|
|
- Bot ↔ Human ↔ Bot transfers
|
|
- Internal notes (not visible to customers)
|
|
- Quick replies for faster responses
|
|
- Real-time supervisor monitoring
|
|
- SLA tracking foundation
|
|
- CSAT collection
|