Files
clientflow_backend/app/admin_ui/pages/communications.py
2026-06-09 22:55:58 +01:00

202 lines
13 KiB
Python

"""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"""
<tr>
<td><a class="cf-row-link" href="/communications/{esc(item.get('id'))}">{esc(item.get('subject') or 'Sem assunto')}</a><div class="small text-secondary text-break">{esc(item.get('sender_name') or item.get('sender_email') or '')} · {esc(fmt_dt(item.get('created_at')))}</div></td>
<td><span class="cf-chip {esc(action.get('chip'))}">{esc(item.get('classification') or 'por classificar')}</span><div class="small text-secondary">confiança {esc(confidence_label)}</div></td>
<td>{status_badge(item.get('status'))}<div class="small text-secondary">{esc(action.get('queue'))} · {esc(action.get('action'))}</div></td>
<td class="small text-secondary text-break">{esc(' · '.join(linked))}</td>
<td><a class="btn btn-sm btn-primary" href="/communications/{esc(item.get('id'))}">Abrir</a></td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="5" class="text-secondary py-4">Sem comunicações para os filtros selecionados.</td></tr>'
status_options = ["", "new", "classified", "needs_review", "linked", "task_created", "done", "ignored"]
status_select = "".join(f'<option value="{esc(v)}" {"selected" if (status or "") == v else ""}>{esc(v or "todos")}</option>' for v in status_options)
body = f"""
<section class="alert alert-primary border-0 shadow-sm">
<strong>Comunicações = emails/mensagens classificados.</strong>
Quando uma comunicação exige ação humana, ela também aparece no Centro de trabalho e pode gerar task.
</section>
<section class="cf-kpi-grid">
{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')}
</section>
<form class="filters row g-2 align-items-end" method="get" action="/communications">
<div class="col-md-4"><label class="form-label small fw-bold">Pesquisar</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="email, assunto, cliente..."></div>
<div class="col-md-3"><label class="form-label small fw-bold">Estado</label><select class="form-select" name="status">{status_select}</select></div>
<div class="col-md-3"><label class="form-label small fw-bold">Classificação</label><input class="form-control" type="text" name="classification" value="{esc(classification or '')}" placeholder="pedido_orcamento"></div>
<div class="col-md-2"><button class="btn btn-primary w-100" type="submit">Filtrar</button></div>
</form>
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Inbox classificada</h2><div class="small text-secondary">Emails e mensagens recebidas, com classificação e contexto operacional.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Mensagem</th><th>Classificação</th><th>Estado / ação sugerida</th><th>Cliente / oportunidade</th><th></th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
"""
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", '<section class="cf-empty">Comunicação não encontrada.</section>', "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 = '<option value="">Sem cliente associado</option>'
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'<option value="{esc(c.get("id"))}" {selected}>{esc(label)}</option>'
opportunity_options = '<option value="">Sem oportunidade associada</option>'
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'<option value="{esc(o.get("id"))}" {selected}>{esc(label)}</option>'
body = f"""
<a class="cf-row-link d-inline-flex mb-3" href="/communications">← Voltar a Comunicações</a>
<div class="cf-two-col">
<main class="d-grid gap-3">
<section class="card cf-card"><div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3"><div><div class="text-primary fw-bold">Comunicação</div><h1 class="h4 mb-1">{esc(item.get('subject') or 'Sem assunto')}</h1><div class="text-secondary">{esc(item.get('sender_name') or '')} · {esc(item.get('sender_email') or '')} · {esc(fmt_dt(item.get('created_at')))}</div></div>{status_badge(item.get('status'))}</div>
<div class="d-flex flex-wrap gap-2 mb-3"><span class="cf-chip {esc(action.get('chip'))}">{esc(item.get('classification') or 'por classificar')}</span><span class="cf-chip cf-chip-gray">confiança {esc(confidence_label)}</span><span class="cf-chip cf-chip-blue">{esc(action.get('queue'))}</span></div>
<div class="cf-copy-box">{esc(item.get('body') or 'Sem corpo guardado.')}</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Ação sugerida</h2><div class="cf-action-focus"><strong>{esc(action.get('action'))}</strong><div class="small text-secondary mt-1">Esta ação deve aparecer no Centro de trabalho se a comunicação estiver por tratar.</div></div></div></section>
</main>
<aside class="cf-side">
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Associar contexto</h2>
<form method="post" action="/communications/{esc(communication_id)}/link-customer" class="d-grid gap-2 mb-3"><label class="form-label small fw-bold">Cliente</label><select class="form-select" name="customer_id">{customer_options}</select><button class="btn btn-outline-primary" type="submit">Guardar cliente</button></form>
<form method="post" action="/communications/{esc(communication_id)}/link-opportunity" class="d-grid gap-2"><label class="form-label small fw-bold">Oportunidade</label><select class="form-select" name="opportunity_id">{opportunity_options}</select><button class="btn btn-outline-primary" type="submit">Guardar oportunidade</button></form>
</div></section>
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Estado</h2><div class="d-grid gap-2">
<form method="post" action="/communications/{esc(communication_id)}/status"><input type="hidden" name="status" value="done"><button class="btn btn-success w-100" type="submit">Marcar tratado</button></form>
<form method="post" action="/communications/{esc(communication_id)}/status"><input type="hidden" name="status" value="needs_review"><button class="btn btn-warning w-100" type="submit">Rever classificação</button></form>
<form method="post" action="/communications/{esc(communication_id)}/status"><input type="hidden" name="status" value="ignored"><button class="btn btn-outline-secondary w-100" type="submit">Ignorar</button></form>
</div></div></section>
</aside>
</div>
"""
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)