202 lines
13 KiB
Python
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)
|
|
|
|
|