"""Communication diagnostic routes. Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 router = APIRouter() @router.get("/communications", response_class=HTMLResponse) @router.get("/comunicacoes", response_class=HTMLResponse) async def communications_page(status: Optional[str] = None, classification: Optional[str] = None, q: Optional[str] = None, scope: Optional[str] = None): items = list_communications(status=status, classification=classification, q=q, limit=120) if scope == "without_customer": items = [i for i in items if not i.get("customer_id") and str(i.get("direction") or "inbound") == "inbound"] elif scope == "without_opportunity": items = [i for i in items if not i.get("opportunity_id") and str(i.get("direction") or "inbound") == "inbound"] elif scope == "needs_review": items = [i for i in items if str(i.get("status") or "") == "needs_review"] summary = get_communications_summary() rows = "" for item in items: action = classification_action(item.get("classification")) confidence = item.get("confidence") confidence_label = "—" if confidence is None else f"{float(confidence):.2f}" linked = [] if item.get("customer_name"): linked.append(str(item.get("customer_name"))) elif item.get("customer_id"): linked.append("cliente associado") else: linked.append("sem cliente") if item.get("opportunity_title"): linked.append(str(item.get("opportunity_title"))) elif item.get("opportunity_id"): linked.append("oportunidade associada") else: linked.append("sem oportunidade") rows += f""" {esc(item.get('subject') or 'Sem assunto')}
{esc(item.get('sender_name') or item.get('sender_email') or '—')} · {esc(fmt_dt(item.get('created_at')))}
{esc(item.get('classification') or 'por classificar')}
confiança {esc(confidence_label)}
{status_badge(item.get('status'))}
{esc(action.get('queue'))} · {esc(action.get('action'))}
{esc(' · '.join(linked))} Abrir """ if not rows: rows = 'Sem comunicações para os filtros selecionados.' status_options = ["", "new", "classified", "needs_review", "linked", "task_created", "done", "ignored"] status_select = "".join(f'' for v in status_options) body = f"""
Comunicações = emails/mensagens classificados. Quando uma comunicação exige ação humana, ela também aparece no Centro de trabalho e pode gerar task.
{kpi_card('Total', summary.get('total', 0), '/communications', 'comunicações registadas', 'bi-inbox')} {kpi_card('Por tratar', summary.get('open', 0), '/communications', 'new/classified/review', 'bi-envelope-exclamation')} {kpi_card('Baixa confiança', summary.get('needs_review', 0), '/communications?status=needs_review', 'precisa revisão', 'bi-shield-exclamation')} {kpi_card('Sem cliente', summary.get('without_customer', 0), '/communications?scope=without_customer', 'associar contacto', 'bi-person-plus')}

Inbox classificada

Emails e mensagens recebidas, com classificação e contexto operacional.
{rows}
MensagemClassificaçãoEstado / ação sugeridaCliente / oportunidade
""" return layout("Comunicações", "Inbox classificada de emails e mensagens", body, "communications") @router.get("/communications/{communication_id}", response_class=HTMLResponse) async def communication_detail_page(communication_id: str): item = get_communication(communication_id) if not item: return layout("Comunicação não encontrada", "Inbox classificada", '
Comunicação não encontrada.
', "communications") action = classification_action(item.get("classification")) confidence = item.get("confidence") confidence_label = "—" if confidence is None else f"{float(confidence):.2f}" try: from app.commercial_service import list_customers customers = list_customers(limit=200) except Exception: customers = [] try: opportunities = list_opportunities(limit=200, status="open") except Exception: opportunities = [] customer_options = '' for c in customers: selected = "selected" if str(c.get("id")) == str(item.get("customer_id")) else "" label = f"{c.get('name') or 'Cliente'} · {c.get('tax_id') or c.get('email') or 'sem NIF'}" customer_options += f'' opportunity_options = '' for o in opportunities: selected = "selected" if str(o.get("id")) == str(item.get("opportunity_id")) else "" label = f"{o.get('title') or 'Oportunidade'} · {o.get('linked_customer_name') or o.get('customer_name') or 'sem cliente'}" opportunity_options += f'' body = f""" ← Voltar a Comunicações
Comunicação

{esc(item.get('subject') or 'Sem assunto')}

{esc(item.get('sender_name') or '')} · {esc(item.get('sender_email') or '—')} · {esc(fmt_dt(item.get('created_at')))}
{status_badge(item.get('status'))}
{esc(item.get('classification') or 'por classificar')}confiança {esc(confidence_label)}{esc(action.get('queue'))}
{esc(item.get('body') or 'Sem corpo guardado.')}

Ação sugerida

{esc(action.get('action'))}
Esta ação deve aparecer no Centro de trabalho se a comunicação estiver por tratar.
""" return layout(str(item.get("subject") or "Comunicação"), "Email/mensagem classificado com contexto", body, "communications") @router.post("/communications/{communication_id}/status") async def communication_set_status_action(communication_id: str, request: Request): form = await request.form() status = str(form.get("status") or "").strip() try: set_communication_status(communication_id, status) item = get_communication(communication_id) if item and item.get("opportunity_id"): create_timeline_event( opportunity_id=str(item.get("opportunity_id")), customer_id=str(item.get("customer_id") or "") or None, event_type=f"communication_{status}", title=f"Comunicação marcada como {status}", description=str(item.get("subject") or item.get("sender_email") or ""), source="communications", related_type="communication", related_id=communication_id, created_by="operator", ) except Exception as exc: return PlainTextResponse(f"Erro ao atualizar comunicação: {exc}", status_code=500) return RedirectResponse(f"/communications/{communication_id}", status_code=303) @router.post("/communications/{communication_id}/link-customer") async def communication_link_customer_action(communication_id: str, request: Request): form = await request.form() customer_id = str(form.get("customer_id") or "").strip() try: link_communication_to_customer(communication_id, customer_id or None) except Exception as exc: return PlainTextResponse(f"Erro ao associar cliente: {exc}", status_code=500) return RedirectResponse(f"/communications/{communication_id}", status_code=303) @router.post("/communications/{communication_id}/link-opportunity") async def communication_link_opportunity_action(communication_id: str, request: Request): form = await request.form() opportunity_id = str(form.get("opportunity_id") or "").strip() try: link_communication_to_opportunity(communication_id, opportunity_id or None) if opportunity_id: item = get_communication(communication_id) create_timeline_event( opportunity_id=opportunity_id, customer_id=str(item.get("customer_id") or "") if item else None, event_type="communication_linked", title="Comunicação associada à oportunidade", description=str((item or {}).get("subject") or (item or {}).get("sender_email") or ""), source="communications", related_type="communication", related_id=communication_id, created_by="operator", ) except Exception as exc: return PlainTextResponse(f"Erro ao associar oportunidade: {exc}", status_code=500) return RedirectResponse(f"/communications/{communication_id}", status_code=303)