Files
clientflow_backend/app/admin_ui/pages/dashboard.py

129 lines
8.3 KiB
Python

"""Executive dashboard and landing routes for the ClientFlow workbench."""
from __future__ import annotations
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.revenue_forecast_service import get_revenue_forecast
router = APIRouter()
# Legacy regression marker: Dashboard = visibilidade.
def _stage_map(forecast: dict) -> dict[str, dict]:
return {str(row.get("stage") or "").upper(): row for row in forecast.get("stage_summary", [])}
@router.get("/", response_class=HTMLResponse)
async def admin_home():
"""Executive visibility: target, funnel health, blockers and priorities."""
metrics = get_admin_dashboard_metrics()
ops = get_operations_summary(limit=10)
counts = ops.get("counts") or {}
comms = get_communications_summary()
try:
forecast = get_revenue_forecast(limit=1000, metric="invoiced")
except Exception:
forecast = {
"summary": {},
"management": {"month": {}, "status": {}, "recoverable": {}},
"stage_summary": [],
"priority_actions": [],
}
summary = forecast.get("summary") or {}
management = forecast.get("management") or {}
month = management.get("month") or {}
target_amount = float(management.get("target_amount") or 0)
status = management.get("status") or {}
stage_map = _stage_map(forecast)
def n(key: str) -> int:
return int(metrics.get(key) or counts.get(key) or 0)
waiting = stage_map.get("WAITING_PAYMENT", {})
ready_count = sum(int((stage_map.get(stage) or {}).get("count") or 0) for stage in ("READY_TO_SHIP", "SHIPMENT_CREATED"))
valued = int(summary.get("valued_opportunities") or 0)
unvalued = int(summary.get("unvalued_opportunities") or 0)
dashboard_cards = []
if target_amount > 0:
dashboard_cards.extend([
("Meta mensal", money_html(target_amount), "/forecast", management.get("metric_label") or "Faturação emitida"),
("Realizado", money_html(month.get("realised") or 0), "/forecast", f"{int(summary.get('realised_count') or 0)} registo(s) no mês"),
("Previsão base", money_html(month.get("forecast_total") or 0), "/forecast", str(status.get("label") or "Sem classificação")),
("Desvio", money_html(management.get("gap") or 0), "/forecast", "Falta para a meta" if management.get("gap") else "Meta suportada"),
])
dashboard_cards.extend([
("Oportunidades abertas", counts.get("open_opportunities", 0), "/opportunities?status=open", f"{valued} com valor · {unvalued} por valorizar"),
("Tasks pendentes", n("pending_total"), "/operations", f"{int(counts.get('overdue_tasks') or 0)} atrasada(s)"),
("A aguardar pagamento", int(waiting.get("count") or 0), "/operations?scope=financeiro", f"Valor conhecido {money_html(waiting.get('gross') or 0)}"),
("Prontos/envio criado", ready_count, "/operations?scope=logistica", "Trabalho logístico ainda por concluir"),
("Mensagens a rever", comms.get("open", 0), "/operations", "Ações vindas do Chatwoot"),
("Erros de integração", counts.get("outbox_failed", 0), "/outbox?status=failed", "Jasmin / Packlink / outbox"),
("Clientes incompletos", counts.get("customers_incomplete", 0), "/customers", "Total global; priorizar os que bloqueiam vendas"),
("Produtos bloqueantes", counts.get("products_missing_jasmin", 0), "/products?active=missing_jasmin", "Ativos sem Artigo Jasmin"),
])
cards_html = "".join(kpi_card(label, value, href, hint) for label, value, href, hint in dashboard_cards)
alert_items = []
if unvalued:
severity = "cf-chip-red" if (summary.get("value_coverage") or 0) < 0.5 else "cf-chip-orange"
alert_items.append(("Funil sem valor", f"{unvalued} oportunidade(s) sem valor comercial", "/forecast", severity))
if int(waiting.get("count") or 0):
alert_items.append(("Pagamentos a acelerar", f"{int(waiting.get('count') or 0)} oportunidade(s) · {money_html(waiting.get('gross') or 0)}", "/operations?scope=financeiro", "cf-chip-orange"))
if ready_count:
alert_items.append(("Logística pendente", f"{ready_count} processo(s) pronto(s) ou com envio criado", "/operations?scope=logistica", "cf-chip-orange"))
if int(counts.get("outbox_failed") or 0):
alert_items.append(("Erro de integração", f"{counts.get('outbox_failed')} ação(ões) falhadas", "/outbox?status=failed", "cf-chip-red"))
if int(counts.get("products_missing_jasmin") or 0):
alert_items.append(("Produto bloqueante", f"{counts.get('products_missing_jasmin')} produto(s) sem Artigo Jasmin", "/products?active=missing_jasmin", "cf-chip-red"))
alert_html = "".join(
f'''<a class="d-flex justify-content-between align-items-start gap-3 py-3 border-bottom text-reset" href="{esc(href)}"><div><span class="cf-chip {esc(chip)} mb-2">{esc(title)}</span><div class="fw-bold">{detail}</div></div><span class="text-primary fw-bold">Abrir →</span></a>'''
for title, detail, href, chip in alert_items[:5]
) or '<div class="text-secondary py-3">Sem alertas críticos neste momento.</div>'
action_rows = ""
for action in (forecast.get("priority_actions") or [])[:5]:
action_rows += f'''
<tr>
<td><a href="/opportunities/{esc(action.get('id'))}"><strong>{esc(action.get('customer_name') or action.get('title') or 'Oportunidade')}</strong></a></td>
<td>{esc(action.get('recommended_action') or 'Abrir e rever')}</td>
<td class="text-end">{money_html(action.get('impact_amount') or action.get('amount') or 0)}</td>
</tr>'''
if not action_rows:
action_rows = '<tr><td colspan="3" class="text-center text-secondary py-4">Sem ações comerciais prioritárias calculadas.</td></tr>'
body = f"""
<section class="alert alert-primary border-0 shadow-sm d-flex flex-wrap justify-content-between align-items-center gap-3">
<div><strong>Dashboard executivo.</strong><span class="ms-1">Mostra desempenho, saúde do funil, bloqueios e prioridades. O Centro de trabalho continua a organizar a execução diária.</span></div>
<div class="d-flex gap-2"><a class="btn btn-outline-primary" href="/forecast">Metas e previsão</a><a class="btn btn-primary" href="/operations">Abrir Centro de trabalho</a></div>
</section>
<section class="cf-kpi-grid">{cards_html}</section>
<div class="row g-3">
<div class="col-xl-7">
<section class="card cf-card h-100"><div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="cf-section-title">Alertas</h2><div class="small text-secondary">Sinais globais com impacto comercial ou operacional.</div></div><a class="btn btn-sm btn-outline-primary" href="/operations">Resolver</a></div>
{alert_html}
</div></section>
</div>
<div class="col-xl-5">
<section class="card cf-card h-100"><div class="card-body p-0">
<div class="p-4 border-bottom"><h2 class="cf-section-title mb-1">Ações de maior impacto</h2><div class="small text-secondary">Prioridades calculadas a partir do funil valorizado.</div></div>
<div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table mb-0"><thead><tr><th>Cliente</th><th>Ação</th><th class="text-end">Impacto</th></tr></thead><tbody>{action_rows}</tbody></table></div>
</div></section>
</div>
</div>
<section class="card cf-card mt-3"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Modelo operacional v4.7</h2><div class="row g-3"><div class="col-md-3"><div class="cf-soft-box h-100"><strong>Dashboard</strong><div class="small text-secondary">Estado, desempenho e riscos.</div></div></div><div class="col-md-3"><div class="cf-soft-box h-100"><strong>Centro de trabalho</strong><div class="small text-secondary">Próximo trabalho humano.</div></div></div><div class="col-md-3"><div class="cf-soft-box h-100"><strong>Metas e previsão</strong><div class="small text-secondary">Meta, desvio, recuperação e pipeline.</div></div></div><div class="col-md-3"><div class="cf-soft-box h-100"><strong>Oportunidade</strong><div class="small text-secondary">Contexto, documentos, tasks e timeline.</div></div></div></div></div></section>
"""
return layout("Dashboard", "Visão executiva do negócio e do sistema", body, "overview")