Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
@@ -4,11 +4,19 @@ 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 re
|
||||
from fastapi.responses import PlainTextResponse
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||||
|
||||
def _valid_optional_email(value: str) -> bool:
|
||||
value = str(value or "").strip()
|
||||
return not value or bool(_EMAIL_RE.match(value))
|
||||
|
||||
|
||||
@router.get("/customers", response_class=HTMLResponse)
|
||||
@router.get("/clientes", response_class=HTMLResponse)
|
||||
@@ -70,13 +78,39 @@ async def create_customer_action(request: Request):
|
||||
"email": str(form.get("email") or "").strip(),
|
||||
"phone": str(form.get("phone") or "").strip(),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return PlainTextResponse(f"Dados inválidos ao criar cliente: {exc}", status_code=422)
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(f"Erro ao criar cliente: {exc}", status_code=500)
|
||||
return RedirectResponse(f"/customers/{customer.get('id')}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/customers/new", response_class=HTMLResponse)
|
||||
async def customer_new_page(name: Optional[str] = None, tax_id: Optional[str] = None, email: Optional[str] = None):
|
||||
body = f"""
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/customers">← Voltar a clientes</a>
|
||||
<section class="card cf-card">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="h4 fw-bold mb-1">Novo cliente fiscal</h1>
|
||||
<div class="text-secondary mb-4">Cria uma ficha fiscal para associar a oportunidades, documentos Jasmin e processos de envio.</div>
|
||||
<form method="post" action="/customers/create" class="row g-3">
|
||||
<div class="col-12"><label class="form-label small fw-bold text-secondary">Nome fiscal</label><input class="form-control" name="name" value="{esc(name or '')}" placeholder="Nome fiscal" required></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">NIF</label><input class="form-control" name="tax_id" value="{esc(tax_id or '')}" placeholder="NIF"></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Email</label><input class="form-control" name="email" value="{esc(email or '')}" placeholder="email@empresa.pt"></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Telefone</label><input class="form-control" name="phone" placeholder="Telefone"></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">País</label><input class="form-control" name="country" value="PT"></div>
|
||||
<div class="col-12 d-flex gap-2"><button class="btn btn-primary" type="submit">Criar ficha</button><a class="btn btn-outline-secondary" href="/customers">Cancelar</a></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
return layout("Novo cliente", "Criar ficha fiscal", body, "customers")
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}", response_class=HTMLResponse)
|
||||
async def customer_detail_page(customer_id: str):
|
||||
if not is_uuid_text(customer_id):
|
||||
return PlainTextResponse("Identificador de cliente inválido.", status_code=422)
|
||||
try:
|
||||
from app.commercial_service import get_customer, list_commercial_documents, list_opportunities_for_customer, list_shipments
|
||||
customer = get_customer(customer_id)
|
||||
@@ -131,7 +165,7 @@ async def customer_detail_page(customer_id: str):
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3"><div><h2 class="cf-section-title mb-1">Nova oportunidade</h2><div class="small text-secondary">Cria um processo comercial manual já ligado a este cliente fiscal.</div></div></div>
|
||||
<form method="post" action="/customers/{esc(customer_id)}/opportunities/create" class="row g-2 align-items-end">
|
||||
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Origem</label><select class="form-select" name="origin"><option value="phone">Telefone</option><option value="whatsapp">WhatsApp</option><option value="email">Email</option><option value="presential">Presencial</option><option value="manual">Manual</option></select></div>
|
||||
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Pedido</label><select class="form-select" name="request_type"><option value="quote">Orçamento</option><option value="info">Informação</option><option value="proforma">Pró-forma</option><option value="invoice">Fatura</option><option value="order">Encomenda</option><option value="support">Assistência</option></select></div>
|
||||
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Pedido</label><select class="form-select" name="request_type"><option value="quote">Orçamento</option><option value="info">Informação</option><option value="invoice">Fatura</option><option value="order">Encomenda</option><option value="support">Assistência</option></select></div>
|
||||
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Ex.: carregador monofásico, cabo, instalação..."></div>
|
||||
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Contacto</label><input class="form-control" name="contact_name" placeholder="Nome do contacto"></div>
|
||||
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Email contacto</label><input class="form-control" name="contact_email" placeholder="email@empresa.pt"></div>
|
||||
@@ -152,7 +186,17 @@ async def customer_detail_page(customer_id: str):
|
||||
|
||||
@router.post("/customers/{customer_id}/opportunities/create")
|
||||
async def create_customer_opportunity_action(customer_id: str, request: Request):
|
||||
if not is_uuid_text(customer_id):
|
||||
return PlainTextResponse("Identificador de cliente inválido.", status_code=422)
|
||||
form = await request.form()
|
||||
contact_email = str(form.get("contact_email") or "").strip()
|
||||
if not _valid_optional_email(contact_email):
|
||||
return PlainTextResponse("Email de contacto inválido.", status_code=422)
|
||||
meaningful = any(str(form.get(k) or "").strip() for k in ("contact_name", "contact_phone", "product_interest", "notes")) or bool(contact_email)
|
||||
if not meaningful:
|
||||
return PlainTextResponse("Dados insuficientes para criar oportunidade.", status_code=422)
|
||||
if contact_email and not any(str(form.get(k) or "").strip() for k in ("contact_name", "contact_phone", "product_interest", "notes")):
|
||||
return PlainTextResponse("Dados insuficientes para criar oportunidade: indique produto, notas ou outro contacto válido.", status_code=422)
|
||||
try:
|
||||
from app.opportunity_service import create_manual_opportunity_from_customer
|
||||
result = create_manual_opportunity_from_customer(
|
||||
@@ -167,6 +211,8 @@ async def create_customer_opportunity_action(customer_id: str, request: Request)
|
||||
create_task=bool(form.get("create_task")),
|
||||
created_by="operator",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return PlainTextResponse(f"Dados inválidos ao criar oportunidade: {exc}", status_code=422)
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(f"Erro ao criar oportunidade: {exc}", status_code=500)
|
||||
return RedirectResponse(result.get("next_url") or f"/customers/{customer_id}", status_code=303)
|
||||
@@ -174,6 +220,8 @@ async def create_customer_opportunity_action(customer_id: str, request: Request)
|
||||
|
||||
@router.post("/customers/{customer_id}/update")
|
||||
async def update_customer_action(customer_id: str, request: Request):
|
||||
if not is_uuid_text(customer_id):
|
||||
return PlainTextResponse("Identificador de cliente inválido.", status_code=422)
|
||||
form = await request.form()
|
||||
try:
|
||||
from app.commercial_service import update_customer
|
||||
@@ -189,6 +237,8 @@ async def update_customer_action(customer_id: str, request: Request):
|
||||
"jasmin_customer_party_key": str(form.get("jasmin_customer_party_key") or "").strip(),
|
||||
"jasmin_customer_id": str(form.get("jasmin_customer_id") or "").strip(),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return PlainTextResponse(f"Dados inválidos ao guardar cliente: {exc}", status_code=422)
|
||||
except Exception as exc:
|
||||
# Keep database details out of the operator UI. Duplicate NIFs are a
|
||||
# business conflict, not a technical 500.
|
||||
|
||||
@@ -1,102 +1,128 @@
|
||||
"""Dashboard and landing routes.
|
||||
"""Executive dashboard and landing routes for the ClientFlow workbench."""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
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():
|
||||
"""v4.5 clean Dashboard: visibility, not daily execution."""
|
||||
"""Executive visibility: target, funnel health, blockers and priorities."""
|
||||
metrics = get_admin_dashboard_metrics()
|
||||
ops = get_operations_summary(limit=6)
|
||||
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)
|
||||
|
||||
dashboard_cards = [
|
||||
("Oportunidades abertas", counts.get("open_opportunities", 0), "/opportunities?status=open", "Negócio em acompanhamento"),
|
||||
("Valor / documentos", counts.get("open_quotations", 0), "/finance", "Orçamentos abertos"),
|
||||
("Tasks pendentes", n("pending_total"), "/operations", "Trabalho humano por resolver"),
|
||||
("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"),
|
||||
("Pagamentos por confirmar", n("pending_financeiro"), "/operations", "Fila financeira"),
|
||||
("Envios pendentes", counts.get("shipments_pending", 0), "/orders", "Logística/Packlink"),
|
||||
("Clientes incompletos", counts.get("customers_incomplete", 0), "/customers", "Dados fiscais/morada"),
|
||||
]
|
||||
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)
|
||||
|
||||
cards_html = ""
|
||||
for label, value, href, hint in dashboard_cards:
|
||||
cards_html += kpi_card(label, value, href, hint)
|
||||
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 na outbox", "/outbox?status=failed", "cf-chip-red"))
|
||||
if int(comms.get("needs_review") or 0):
|
||||
alert_items.append(("Rever comunicação", f"{comms.get('needs_review')} mensagem(ns) com baixa confiança", "/operations", "cf-chip-orange"))
|
||||
if int(counts.get("customers_incomplete") or 0):
|
||||
alert_items.append(("Dados incompletos", f"{counts.get('customers_incomplete')} cliente(s) sem dados fiscais/morada completos", "/customers", "cf-chip-orange"))
|
||||
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) ativos sem Artigo Jasmin", "/products?active=missing_jasmin", "cf-chip-red"))
|
||||
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 = ""
|
||||
for title, detail, href, chip in alert_items[:5]:
|
||||
alert_html += 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">{esc(detail)}</div></div>
|
||||
<span class="text-primary fw-bold">Abrir →</span>
|
||||
</a>
|
||||
"""
|
||||
if not alert_html:
|
||||
alert_html = '<div class="text-secondary py-3">Sem alertas críticos neste momento.</div>'
|
||||
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 = visibilidade.</strong>
|
||||
<span class="ms-1">O Chatwoot é a inbox. O ClientFlow mostra o trabalho, bloqueios e próximas ações.</span>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="/operations">Abrir Centro de trabalho</a>
|
||||
<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>
|
||||
<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 que merecem atenção.</div></div>
|
||||
<a class="btn btn-sm btn-outline-primary" href="/operations">Resolver no Centro de trabalho</a>
|
||||
</div>
|
||||
{alert_html}
|
||||
</div>
|
||||
</section>
|
||||
<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-4">
|
||||
<h2 class="cf-section-title mb-3">Modelo operacional v4.5</h2>
|
||||
<div class="d-grid gap-3">
|
||||
<div class="cf-soft-box"><strong>Dashboard</strong><div class="small text-secondary">Mostra o estado e gargalos.</div></div>
|
||||
<div class="cf-soft-box"><strong>Centro de trabalho</strong><div class="small text-secondary">Organiza o que precisa de ação agora.</div></div>
|
||||
<div class="cf-soft-box"><strong>Chatwoot → ClientFlow</strong><div class="small text-secondary">O Chatwoot continua a ser a inbox; o ClientFlow transforma mensagens em ações, tasks e timeline.</div></div>
|
||||
<div class="cf-soft-box"><strong>Oportunidade</strong><div class="small text-secondary">Mantém contexto, documentos, tasks, outbox e timeline.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<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 geral do negócio e do sistema", body, "overview")
|
||||
|
||||
|
||||
return layout("Dashboard", "Visão executiva do negócio e do sistema", body, "overview")
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
from urllib.parse import quote
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
@@ -14,6 +15,7 @@ router = APIRouter()
|
||||
@router.get("/financeiro", response_class=HTMLResponse)
|
||||
async def finance_page(q: Optional[str] = None):
|
||||
finance_tasks = list_tasks(status=None, route="financeiro", q=q, limit=200)
|
||||
return_to = "/finance" + (f"?q={quote(str(q), safe='')}" if q else "")
|
||||
finance_actions = {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT"}
|
||||
finance_tasks = [t for t in finance_tasks if str(t.get("action_code") or "") in finance_actions or str(t.get("route") or "") == "financeiro"]
|
||||
opportunities = list_opportunities(status="all", q=q, limit=300)
|
||||
@@ -26,11 +28,11 @@ async def finance_page(q: Optional[str] = None):
|
||||
code = str(task.get("action_code") or "")
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td><a class="cf-row-link" href="/tasks/{esc(tid)}">{esc(action_label(code))}</a><div class="small"><code>{esc(code)}</code></div></td>
|
||||
<td><a class="cf-row-link" href="/tasks/{esc(tid)}?return_to={quote(return_to, safe='')}">{esc(action_label(code))}</a><div class="small"><code>{esc(code)}</code></div></td>
|
||||
<td><strong>{esc(customer)}</strong><div class="small text-secondary text-break">{esc(task.get('customer_email') or '')}</div></td>
|
||||
<td>{status_badge(task.get('status'))}</td>
|
||||
<td>{esc(task.get('created_at') or '—')}</td>
|
||||
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/tasks/{esc(tid)}">Abrir</a></td>
|
||||
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/tasks/{esc(tid)}?return_to={quote(return_to, safe='')}">Abrir</a></td>
|
||||
</tr>
|
||||
"""
|
||||
if not rows:
|
||||
@@ -43,8 +45,9 @@ async def finance_page(q: Optional[str] = None):
|
||||
{kpi_card('Pendentes', sum(1 for t in finance_tasks if str(t.get('status')) == 'pending'), '/tasks?status=pending&route=financeiro', 'abrir tarefas', 'bi-list-check')}
|
||||
{kpi_card('Pagamentos confirmados', sum(1 for o in payment_opps if str(o.get('stage')) == 'PAYMENT_CONFIRMED'), '/opportunities', 'seguir para envio', 'bi-check2-circle', 'cf-kpi-tone-green')}
|
||||
</section>
|
||||
<div class="d-flex justify-content-end mb-3"><a class="btn btn-outline-primary" href="/finance/forecast"><i class="bi bi-graph-up-arrow"></i> Meta e desempenho comercial</a></div>
|
||||
<section class="card cf-card cf-filter-card"><form method="get" action="/finance" class="row g-3 align-items-end"><div class="col-lg-8"><label class="form-label small fw-bold text-secondary">Procurar</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="cliente, fatura, pagamento..."></div><div class="col-lg-4 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/finance">Limpar</a></div></form></section>
|
||||
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Financeiro operacional</h2><div class="small text-secondary">Pró-formas, faturas e pagamentos a tratar.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Ação</th><th>Cliente</th><th>Estado</th><th>Criada</th><th></th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
|
||||
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Financeiro operacional</h2><div class="small text-secondary">Orçamentos, faturas e pagamentos a tratar.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Ação</th><th>Cliente</th><th>Estado</th><th>Criada</th><th></th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
|
||||
"""
|
||||
return layout("Financeiro", "O que falta faturar ou confirmar?", body, "finance")
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ async def integrations_page():
|
||||
<form method="post" action="/integrations/odoo/test">
|
||||
<button class="btn btn-outline-primary btn-sm" type="submit">Testar ligação</button>
|
||||
</form>
|
||||
<form method="post" action="/integrations/odoo/sync-products">
|
||||
<form method="post" action="/integrations/odoo/sync-products" hx-confirm="Sincronizar produtos vendáveis do Odoo agora? Esta ação pode atualizar o catálogo e deve ser usada conscientemente.">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Sincronizar produtos vendáveis</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -103,7 +103,7 @@ async def integrations_page():
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="cf-section-title mb-1">Jasmin</h2>
|
||||
<div class="small text-secondary">Pró-formas, faturas e documentos fiscais.</div>
|
||||
<div class="small text-secondary">Orçamentos, faturas e documentos fiscais.</div>
|
||||
</div>
|
||||
{badge(jasmin_enabled)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from urllib.parse import quote
|
||||
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
@@ -23,6 +24,10 @@ from app.admin_ui.view_models.operations import (
|
||||
operation_card_title,
|
||||
operation_primary_label,
|
||||
operation_status_chip,
|
||||
operation_due_label,
|
||||
operation_priority_reason,
|
||||
operation_queue_label,
|
||||
operation_value,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -40,7 +45,7 @@ def _operation_card_class(item: dict) -> str:
|
||||
return card_class
|
||||
|
||||
|
||||
def _render_work_item(item: dict) -> str:
|
||||
def _render_work_item(item: dict, return_to: str = "/operations?scope=all") -> str:
|
||||
high = is_high_priority(item)
|
||||
blocked = is_blocked(item)
|
||||
title = operation_card_title(item)
|
||||
@@ -67,8 +72,21 @@ def _render_work_item(item: dict) -> str:
|
||||
priority_chip = "cf-chip-orange"
|
||||
if status_text == "sem oportunidade comercial":
|
||||
priority_chip = "cf-chip-gray"
|
||||
elif status_text == "associação por confirmar":
|
||||
priority_chip = "cf-chip-purple"
|
||||
elif status_text == "validação obrigatória":
|
||||
priority_chip = "cf-chip-orange"
|
||||
status_chip_html = "" if status_text == "normal" else f'<span class="cf-chip {priority_chip}">{esc(status_text)}</span>'
|
||||
primary = operation_primary_label(item)
|
||||
due_label = operation_due_label(item)
|
||||
queue_text = operation_queue_label(item)
|
||||
value = operation_value(item)
|
||||
value_html = money_html(value) if value > 0 else "Valor por definir"
|
||||
priority_reason = operation_priority_reason(item)
|
||||
primary_href = str(item.get('href') or '#')
|
||||
if primary_href.startswith('/tasks/') and return_to:
|
||||
sep = '&' if '?' in primary_href else '?'
|
||||
primary_href = f"{primary_href}{sep}return_to={quote(return_to, safe='')}"
|
||||
# v4.8.9: details are intentionally not rendered in Operations cards.
|
||||
# Technical metadata remains available in task/opportunity/admin pages, while
|
||||
# the work queue keeps only decision-making information.
|
||||
@@ -83,14 +101,21 @@ def _render_work_item(item: dict) -> str:
|
||||
{status_chip_html}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cf-work-meta d-flex flex-wrap gap-2 small text-secondary mb-2">
|
||||
<span class="badge text-bg-light border">{esc(queue_text)}</span>
|
||||
<span>{esc(due_label)}</span>
|
||||
<span>·</span>
|
||||
<span>{value_html}</span>
|
||||
</div>
|
||||
<div class="cf-work-next compact">
|
||||
<span>Próxima ação</span>
|
||||
<strong>{esc(primary)}</strong>
|
||||
<div class="small text-secondary mt-1 text-break">{esc(detail)}</div>
|
||||
<div class="small mt-2"><strong>Motivo da prioridade:</strong> {esc(priority_reason)}</div>
|
||||
</div>
|
||||
{blockers_html}
|
||||
<div class="cf-work-actions">
|
||||
<a class="btn btn-sm btn-primary cf-work-primary-action" href="{esc(item.get('href') or '#')}">{esc(primary)}</a>
|
||||
<a class="btn btn-sm btn-primary cf-work-primary-action" href="{esc(primary_href)}">{esc(primary)}</a>
|
||||
<div class="cf-work-secondary-actions">
|
||||
{chatwoot_button}
|
||||
{opportunity_button}
|
||||
@@ -99,15 +124,17 @@ def _render_work_item(item: dict) -> str:
|
||||
</article>
|
||||
"""
|
||||
|
||||
def _render_work_group(label: str, items: list[dict]) -> str:
|
||||
def _render_work_group(label: str, items: list[dict], return_to: str = "/operations?scope=all") -> str:
|
||||
if not items:
|
||||
return ""
|
||||
cards = "".join(_render_work_item(item) for item in items)
|
||||
cards = "".join(_render_work_item(item, return_to=return_to) for item in items)
|
||||
return f'<div class="cf-work-section-title">{esc(label)}</div><section class="cf-work-section">{cards}</section>'
|
||||
|
||||
|
||||
def render_operations_work_items(model: dict) -> str:
|
||||
queue_html = _render_work_group("Prioridade alta", model.get("high_items") or []) + _render_work_group("Normal", model.get("normal_items") or [])
|
||||
scope = str(model.get("scope") or "all").strip() or "all"
|
||||
return_to = f"/operations?scope={quote(scope, safe='')}"
|
||||
queue_html = _render_work_group("Prioridade alta", model.get("high_items") or [], return_to=return_to) + _render_work_group("Normal", model.get("normal_items") or [], return_to=return_to)
|
||||
if not queue_html:
|
||||
queue_html = '<div class="cf-work-empty"><strong>Sem trabalho pendente neste filtro.</strong><div class="small mt-1">Quando houver ações humanas ou bloqueios concretos, aparecem aqui.</div></div>'
|
||||
return f'''
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ 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
|
||||
from urllib.parse import quote
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
@@ -117,9 +118,11 @@ async def order_detail_page(opportunity_id: str):
|
||||
if not material_rows:
|
||||
material_rows = f'<tr><td colspan="4" class="text-center text-secondary py-4">Sem produtos definidos. <a href="/opportunities/{esc(opportunity_id)}">Adicionar na oportunidade</a>.</td></tr>'
|
||||
|
||||
return_to = f"/orders/{opportunity_id}"
|
||||
task_rows = ""
|
||||
for task in tasks:
|
||||
task_rows += f'<li><a class="cf-row-link" href="/tasks/{esc(task.get("id"))}">{esc(action_label(task.get("action_code")))}</a> · {status_badge(task.get("status"))}</li>'
|
||||
task_href = f"/tasks/{esc(task.get('id'))}?return_to={quote(return_to, safe='')}"
|
||||
task_rows += f'<li><a class="cf-row-link" href="{esc(task_href)}">{esc(action_label(task.get("action_code")))}</a> · {status_badge(task.get("status"))}</li>'
|
||||
if not task_rows:
|
||||
task_rows = '<li class="text-secondary">Sem tarefas associadas.</li>'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ HTMX table partial so filters and operator actions can refresh the outbox
|
||||
without replacing the full page.
|
||||
"""
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
from app.admin_ui.guidance import outbox_operator_message
|
||||
@@ -142,6 +142,8 @@ async def outbox_page(
|
||||
|
||||
@router.get("/outbox/{outbox_id}", response_class=HTMLResponse)
|
||||
async def outbox_detail(outbox_id: str):
|
||||
if not is_uuid_text(outbox_id):
|
||||
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
|
||||
item = get_outbox_item(outbox_id)
|
||||
|
||||
if not item:
|
||||
@@ -194,6 +196,8 @@ async def outbox_detail(outbox_id: str):
|
||||
|
||||
@router.post("/outbox/{outbox_id}/retry")
|
||||
async def outbox_retry(outbox_id: str, request: Request):
|
||||
if not is_uuid_text(outbox_id):
|
||||
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
|
||||
form = await request.form()
|
||||
opportunity_id = str(form.get("opportunity_id") or "").strip()
|
||||
set_outbox_status(outbox_id=outbox_id, status="pending")
|
||||
@@ -212,6 +216,8 @@ def _outbox_htmx_or_redirect(request: Request, status: str = "all", target_syste
|
||||
|
||||
@router.post("/outbox/{outbox_id}/pending")
|
||||
async def outbox_pending(outbox_id: str, request: Request):
|
||||
if not is_uuid_text(outbox_id):
|
||||
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
|
||||
set_outbox_status(outbox_id=outbox_id, status="pending")
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
record_operator_action_best_effort(action="outbox_reprocess_requested", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "pending"})
|
||||
@@ -220,6 +226,8 @@ async def outbox_pending(outbox_id: str, request: Request):
|
||||
|
||||
@router.post("/outbox/{outbox_id}/sent")
|
||||
async def outbox_sent(outbox_id: str, request: Request):
|
||||
if not is_uuid_text(outbox_id):
|
||||
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
|
||||
set_outbox_status(outbox_id=outbox_id, status="sent")
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
record_operator_action_best_effort(action="outbox_marked_sent", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "sent"})
|
||||
@@ -228,6 +236,8 @@ async def outbox_sent(outbox_id: str, request: Request):
|
||||
|
||||
@router.post("/outbox/{outbox_id}/failed")
|
||||
async def outbox_failed(outbox_id: str, request: Request):
|
||||
if not is_uuid_text(outbox_id):
|
||||
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
|
||||
set_outbox_status(outbox_id=outbox_id, status="failed", error="Marcado manualmente como failed.")
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
record_operator_action_best_effort(action="outbox_marked_failed", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "failed"})
|
||||
@@ -236,6 +246,8 @@ async def outbox_failed(outbox_id: str, request: Request):
|
||||
|
||||
@router.post("/outbox/{outbox_id}/ignored")
|
||||
async def outbox_ignored(outbox_id: str, request: Request):
|
||||
if not is_uuid_text(outbox_id):
|
||||
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
|
||||
set_outbox_status(outbox_id=outbox_id, status="ignored", error="Ignorado manualmente pelo operador.")
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
record_operator_action_best_effort(action="outbox_ignored", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "ignored"})
|
||||
|
||||
@@ -4,12 +4,37 @@ 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
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from fastapi.responses import PlainTextResponse
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _validate_product_form_payload(data: dict) -> str:
|
||||
sku = str(data.get("sku") or "").strip()
|
||||
name = str(data.get("name") or "").strip()
|
||||
if not sku:
|
||||
return "SKU é obrigatório."
|
||||
if not name:
|
||||
return "Nome do produto é obrigatório."
|
||||
for key, label, allow_blank, min_value in [
|
||||
("default_unit_price", "Preço base", True, Decimal("0")),
|
||||
("vat_rate", "IVA", True, Decimal("0")),
|
||||
]:
|
||||
value = data.get(key)
|
||||
if allow_blank and (value is None or str(value).strip() == ""):
|
||||
continue
|
||||
try:
|
||||
decimal_value = Decimal(str(value).replace(",", ".").strip())
|
||||
except (InvalidOperation, ValueError):
|
||||
return f"{label} inválido."
|
||||
if decimal_value < min_value:
|
||||
return f"{label} não pode ser negativo."
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/products", response_class=HTMLResponse)
|
||||
@router.get("/produtos", response_class=HTMLResponse)
|
||||
async def products_page(
|
||||
@@ -186,6 +211,14 @@ async def product_new_page():
|
||||
@router.post("/products")
|
||||
async def product_create(request: Request):
|
||||
data = dict(await request.form())
|
||||
validation_error = _validate_product_form_payload(data)
|
||||
if validation_error:
|
||||
body = f'''
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
|
||||
<section class="alert alert-danger">{esc(validation_error)}</section>
|
||||
<section class="card cf-card"><div class="card-body p-4">{product_form_html(data, action="/products", submit_label="Criar produto")}</div></section>
|
||||
'''
|
||||
return HTMLResponse(layout("Novo produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422)
|
||||
try:
|
||||
product_id = create_product(data)
|
||||
return RedirectResponse(f"/products/{product_id}", status_code=303)
|
||||
@@ -195,11 +228,13 @@ async def product_create(request: Request):
|
||||
<section class="alert alert-danger">Erro ao criar produto: {esc(exc)}</section>
|
||||
<section class="card cf-card"><div class="card-body p-4">{product_form_html(data, action="/products", submit_label="Criar produto")}</div></section>
|
||||
'''
|
||||
return layout("Novo produto", "Corrige os campos e tenta novamente", body, "products")
|
||||
return HTMLResponse(layout("Novo produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}", response_class=HTMLResponse)
|
||||
async def product_detail_page(product_id: str):
|
||||
if not is_uuid_text(product_id):
|
||||
return PlainTextResponse("Identificador de produto inválido.", status_code=422)
|
||||
product = get_product(product_id)
|
||||
if not product:
|
||||
return layout("Produto não encontrado", "Catálogo", '<section class="cf-empty">Produto não encontrado.</section>', "products")
|
||||
@@ -242,7 +277,18 @@ async def product_detail_page(product_id: str):
|
||||
|
||||
@router.post("/products/{product_id}/update")
|
||||
async def product_update(product_id: str, request: Request):
|
||||
if not is_uuid_text(product_id):
|
||||
return PlainTextResponse("Identificador de produto inválido.", status_code=422)
|
||||
data = dict(await request.form())
|
||||
validation_error = _validate_product_form_payload(data)
|
||||
if validation_error:
|
||||
product = get_product(product_id) or data
|
||||
body = f'''
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/products/{esc(product_id)}">← Voltar ao produto</a>
|
||||
<section class="alert alert-danger">{esc(validation_error)}</section>
|
||||
<section class="card cf-card"><div class="card-body p-4">{product_form_html(product, action=f"/products/{product_id}/update", submit_label="Guardar alterações")}</div></section>
|
||||
'''
|
||||
return HTMLResponse(layout("Editar produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422)
|
||||
try:
|
||||
update_product(product_id, data)
|
||||
return RedirectResponse(f"/products/{product_id}", status_code=303)
|
||||
@@ -253,18 +299,24 @@ async def product_update(product_id: str, request: Request):
|
||||
<section class="alert alert-danger">Erro ao guardar produto: {esc(exc)}</section>
|
||||
<section class="card cf-card"><div class="card-body p-4">{product_form_html(product, action=f"/products/{product_id}/update", submit_label="Guardar alterações")}</div></section>
|
||||
'''
|
||||
return layout("Editar produto", "Corrige os campos e tenta novamente", body, "products")
|
||||
return HTMLResponse(layout("Editar produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422)
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/toggle")
|
||||
async def product_toggle(product_id: str, request: Request):
|
||||
if not is_uuid_text(product_id):
|
||||
return PlainTextResponse("Identificador de produto inválido.", status_code=422)
|
||||
data = dict(await request.form())
|
||||
set_product_active(product_id, str(data.get("active") or "false").lower() == "true")
|
||||
ok = set_product_active(product_id, str(data.get("active") or "false").lower() == "true")
|
||||
if not ok:
|
||||
return PlainTextResponse("Produto não encontrado.", status_code=404)
|
||||
return RedirectResponse(f"/products/{product_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/opportunities/{opportunity_id}/items/add")
|
||||
async def opportunity_item_add(opportunity_id: str, request: Request):
|
||||
if not is_uuid_text(opportunity_id):
|
||||
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
||||
data = dict(await request.form())
|
||||
try:
|
||||
add_opportunity_item(
|
||||
@@ -280,7 +332,8 @@ async def opportunity_item_add(opportunity_id: str, request: Request):
|
||||
except Exception as exc:
|
||||
print(f"ClientFlow add opportunity item failed: {exc}", flush=True)
|
||||
if is_htmx(request):
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=409)
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=422)
|
||||
return PlainTextResponse(f"Dados inválidos ao adicionar item: {exc}", status_code=422)
|
||||
if is_htmx(request):
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, notice="Produto adicionado à oportunidade."))
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Produto%20adicionado%20%C3%A0%20oportunidade", status_code=303)
|
||||
@@ -288,12 +341,15 @@ async def opportunity_item_add(opportunity_id: str, request: Request):
|
||||
|
||||
@router.post("/opportunities/{opportunity_id}/items/{item_id}/delete")
|
||||
async def opportunity_item_delete(opportunity_id: str, item_id: str, request: Request):
|
||||
if not is_uuid_text(opportunity_id) or not is_uuid_text(item_id):
|
||||
return PlainTextResponse("Identificador inválido.", status_code=422)
|
||||
try:
|
||||
delete_opportunity_item(item_id)
|
||||
except Exception as exc:
|
||||
print(f"ClientFlow delete opportunity item failed: {exc}", flush=True)
|
||||
if is_htmx(request):
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=409)
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=422)
|
||||
return PlainTextResponse(f"Dados inválidos ao adicionar item: {exc}", status_code=422)
|
||||
if is_htmx(request):
|
||||
return HTMLResponse(opportunity_products_panel_html(opportunity_id, notice="Produto removido."))
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
|
||||
|
||||
@@ -52,7 +52,7 @@ router = APIRouter()
|
||||
|
||||
TYPE_LABELS = {
|
||||
"jasmin_quotation": "Orçamento Jasmin",
|
||||
"jasmin_proforma": "Pró-forma Jasmin",
|
||||
"jasmin_proforma": "Orçamento Jasmin legado",
|
||||
"jasmin_invoice": "Fatura Jasmin",
|
||||
"odoo_sale_order": "Venda Odoo",
|
||||
"payment_proof": "Comprovativo",
|
||||
@@ -85,7 +85,7 @@ def _operation_label(value: str | None) -> str:
|
||||
labels = {
|
||||
"odoo_sale_order": "Venda Odoo",
|
||||
"jasmin_quotation": "Orçamento Jasmin",
|
||||
"jasmin_proforma": "Pró-forma Jasmin",
|
||||
"jasmin_proforma": "Orçamento Jasmin legado",
|
||||
"jasmin_invoice": "Fatura Jasmin",
|
||||
"document": "Documento",
|
||||
}
|
||||
@@ -443,8 +443,8 @@ async def reconciliation_page(status: Optional[str] = "open", external_type: Opt
|
||||
<h2 class="cf-section-title mb-3">Registar pedido externo</h2>
|
||||
<form method="post" action="/reconciliation/manual-request" class="d-grid gap-3">
|
||||
<div class="row g-2"><div class="col-md-4"><label class="form-label small fw-bold">Origem</label><select class="form-select" name="source_channel"><option>WhatsApp</option><option>Telefone</option><option>Email direto</option><option>Presencial</option><option>Outro</option></select></div><div class="col-md-8"><label class="form-label small fw-bold">Nome/contacto</label><input class="form-control" name="customer_name" required></div></div>
|
||||
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Email</label><input class="form-control" name="customer_email"></div><div class="col-md-6"><label class="form-label small fw-bold">Telefone</label><input class="form-control" name="customer_phone"></div></div>
|
||||
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Carregador EV, Cabo..."></div><div class="col-md-6"><label class="form-label small fw-bold">Próxima ação</label><select class="form-select" name="action_code"><option value="SEND_QUOTE">Preparar orçamento</option><option value="SEND_INFO">Preparar resposta</option><option value="SEND_PROFORMA">Emitir pró-forma</option><option value="SEND_INVOICE">Emitir fatura</option><option value="REVIEW_MANUALLY">Rever manualmente</option></select></div></div>
|
||||
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Email</label><input class="form-control" name="customer_email" type="email" autocomplete="off"></div><div class="col-md-6"><label class="form-label small fw-bold">Telefone</label><input class="form-control" name="customer_phone" type="tel" autocomplete="off"></div></div>
|
||||
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Carregador EV, Cabo..."></div><div class="col-md-6"><label class="form-label small fw-bold">Próxima ação</label><select class="form-select" name="action_code"><option value="SEND_QUOTE">Preparar orçamento</option><option value="SEND_INFO">Preparar resposta</option><option value="SEND_PROFORMA">Enviar orçamento para pagamento</option><option value="SEND_INVOICE">Emitir fatura</option><option value="REVIEW_MANUALLY">Rever manualmente</option></select></div></div>
|
||||
<div><label class="form-label small fw-bold">Mensagem/pedido</label><textarea class="form-control" rows="3" name="request_text" placeholder="Colar texto do WhatsApp, telefone ou email direto..."></textarea></div>
|
||||
<div><button class="btn btn-primary" type="submit">Guardar e criar oportunidade</button></div>
|
||||
</form>
|
||||
@@ -506,6 +506,12 @@ async def reconciliation_sync_odoo(request: Request):
|
||||
days = _safe_days(form.get("days"), 3)
|
||||
result = sync_odoo_reconciliation_candidates(limit=50, days=days)
|
||||
notice = f"Odoo últimos {result.get('days', days)} dias: analisadas {result.get('seen', 0)} vendas · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
|
||||
if result.get("already_linked"):
|
||||
notice += f" · já ligadas {result.get('already_linked', 0)}"
|
||||
if result.get("resolved_existing"):
|
||||
notice += f" · candidatos obsoletos resolvidos {result.get('resolved_existing', 0)}"
|
||||
if result.get("link_conflicts"):
|
||||
notice += f" · conflitos de ligação {result.get('link_conflicts', 0)}"
|
||||
if result.get("skipped"):
|
||||
notice += " · " + str(result.get("skipped"))
|
||||
return RedirectResponse(f"/reconciliation?days={days}¬ice={esc(notice)}", status_code=303)
|
||||
|
||||
281
app/admin_ui/pages/revenue_forecast.py
Normal file
281
app/admin_ui/pages/revenue_forecast.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Sales target and management forecast dashboard."""
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.admin_dashboard import esc, money_html, stage_label
|
||||
from app.admin_ui.components import kpi_card
|
||||
from app.admin_ui.layout import layout
|
||||
from app.revenue_forecast_service import TARGET_METRICS, get_revenue_forecast, set_sales_target
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _pct(value: object, digits: int = 0) -> str:
|
||||
try:
|
||||
return f"{float(value or 0) * 100:.{digits}f}%"
|
||||
except Exception:
|
||||
return "0%"
|
||||
|
||||
|
||||
def _number(value: object) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _status_alert(status: dict) -> str:
|
||||
tone = str(status.get("tone") or "gray")
|
||||
css = {"green": "success", "orange": "warning", "red": "danger", "gray": "secondary"}.get(tone, "secondary")
|
||||
return f'<div class="alert alert-{css}"><strong>{esc(status.get("label"))}</strong><div>{esc(status.get("message"))}</div></div>'
|
||||
|
||||
|
||||
def _class_badge(value: str) -> str:
|
||||
mapping = {
|
||||
"realised": ("Realizado", "success"),
|
||||
"committed": ("Comprometido", "primary"),
|
||||
"probable": ("Provável", "warning"),
|
||||
"realised_other_period": ("Já realizado", "secondary"),
|
||||
}
|
||||
label, css = mapping.get(str(value), (value or "—", "secondary"))
|
||||
return f'<span class="badge text-bg-{css}">{esc(label)}</span>'
|
||||
|
||||
|
||||
@router.post("/forecast/target")
|
||||
@router.post("/finance/forecast/target")
|
||||
@router.post("/financeiro/previsao/meta")
|
||||
async def revenue_forecast_target_save(
|
||||
month: str = Form(...),
|
||||
metric: str = Form("invoiced"),
|
||||
target_amount: str = Form("0"),
|
||||
):
|
||||
raw = str(target_amount or "0").strip().replace(" ", "")
|
||||
if "," in raw:
|
||||
raw = raw.replace(".", "").replace(",", ".")
|
||||
try:
|
||||
amount = float(raw)
|
||||
except ValueError:
|
||||
amount = 0.0
|
||||
set_sales_target(month=month, metric=metric, target_amount=amount, updated_by="operator")
|
||||
return RedirectResponse(f"/forecast?{urlencode({'month': month, 'metric': metric})}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/finance/forecast")
|
||||
@router.get("/financeiro/previsao")
|
||||
async def legacy_revenue_forecast(month: str | None = None, metric: str = "invoiced"):
|
||||
params = {"metric": metric}
|
||||
if month:
|
||||
params["month"] = month
|
||||
return RedirectResponse(f"/forecast?{urlencode(params)}", status_code=302)
|
||||
|
||||
|
||||
@router.get("/forecast", response_class=HTMLResponse)
|
||||
async def revenue_forecast_page(month: str | None = None, metric: str = "invoiced"):
|
||||
forecast = get_revenue_forecast(limit=1000, month=month, metric=metric)
|
||||
summary = forecast["summary"]
|
||||
management = forecast["management"]
|
||||
period = forecast["period"]
|
||||
target = forecast["target"]
|
||||
month_data = management["month"]
|
||||
next30 = management["next_30_days"]
|
||||
recoverable = management.get("recoverable") or {}
|
||||
target_amount = _number(management["target_amount"])
|
||||
|
||||
metric_options = "".join(
|
||||
f'<option value="{esc(key)}" {"selected" if key == management["metric"] else ""}>{esc(label)}</option>'
|
||||
for key, label in TARGET_METRICS.items()
|
||||
)
|
||||
month_value = str(period["month_start"])[:7]
|
||||
|
||||
# Progress bar segments stop at the target; any surplus is shown separately.
|
||||
if target_amount > 0:
|
||||
realised_ratio = min(_number(month_data["realised"]) / target_amount, 1.0)
|
||||
remaining = max(1.0 - realised_ratio, 0.0)
|
||||
committed_ratio = min(_number(month_data["committed"]) / target_amount, remaining)
|
||||
remaining = max(remaining - committed_ratio, 0.0)
|
||||
probable_ratio = min(_number(month_data["probable"]) / target_amount, remaining)
|
||||
missing_ratio = max(1.0 - realised_ratio - committed_ratio - probable_ratio, 0.0)
|
||||
progress = f"""
|
||||
<div class="cf-target-progress" role="img" aria-label="Cumprimento previsto da meta">
|
||||
<div class="cf-target-segment cf-target-realised" style="width:{realised_ratio*100:.2f}%" title="Realizado"></div>
|
||||
<div class="cf-target-segment cf-target-committed" style="width:{committed_ratio*100:.2f}%" title="Comprometido"></div>
|
||||
<div class="cf-target-segment cf-target-probable" style="width:{probable_ratio*100:.2f}%" title="Provável"></div>
|
||||
<div class="cf-target-segment cf-target-missing" style="width:{missing_ratio*100:.2f}%" title="Falta"></div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3 small mt-2">
|
||||
<span><i class="cf-dot cf-dot-realised"></i> Realizado {money_html(month_data['realised'])}</span>
|
||||
<span><i class="cf-dot cf-dot-committed"></i> Comprometido {money_html(month_data['committed'])}</span>
|
||||
<span><i class="cf-dot cf-dot-probable"></i> Provável {money_html(month_data['probable'])}</span>
|
||||
<span><i class="cf-dot cf-dot-missing"></i> Falta {money_html(management['gap'])}</span>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
progress = '<div class="text-secondary">Configura uma meta para visualizar o progresso e o desvio.</div>'
|
||||
|
||||
diagnostics_html = "".join(
|
||||
f"""
|
||||
<div class="d-flex gap-3 py-3 border-bottom">
|
||||
<div><span class="badge text-bg-{'danger' if d.get('severity') == 'high' else 'warning'}">{esc(d.get('severity'))}</span></div>
|
||||
<div><strong>{esc(d.get('label'))}</strong><div class="small text-secondary">{esc(d.get('detail'))}</div></div>
|
||||
</div>
|
||||
"""
|
||||
for d in forecast.get("diagnostics", [])
|
||||
) or '<div class="text-secondary py-4">Sem riscos relevantes identificados com os dados atuais.</div>'
|
||||
|
||||
actions_rows = ""
|
||||
for action in forecast.get("priority_actions", []):
|
||||
impact = action.get("impact_amount") or 0
|
||||
flags = []
|
||||
if action.get("overdue_tasks"):
|
||||
flags.append(f"{action['overdue_tasks']} task(s) vencida(s)")
|
||||
if action.get("has_conflict"):
|
||||
flags.append("conflito de identidade")
|
||||
actions_rows += f"""
|
||||
<tr>
|
||||
<td><span class="badge text-bg-light border">{esc(action.get('action_group'))}</span></td>
|
||||
<td><a href="/opportunities/{esc(action.get('id'))}"><strong>{esc(action.get('customer_name') or action.get('title') or 'Oportunidade')}</strong></a><div class="small text-secondary">{esc(action.get('title') or '')}</div></td>
|
||||
<td>{esc(stage_label(action.get('stage')))}</td>
|
||||
<td>{money_html(action.get('amount') or 0)}</td>
|
||||
<td><strong>{esc(action.get('recommended_action'))}</strong><div class="small text-secondary">{esc(', '.join(flags) or '—')}</div></td>
|
||||
<td>{money_html(impact)}</td>
|
||||
</tr>
|
||||
"""
|
||||
if not actions_rows:
|
||||
actions_rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem ações prioritárias calculadas.</td></tr>'
|
||||
|
||||
realised_rows = ""
|
||||
for item in forecast.get("realised_items", [])[:80]:
|
||||
realised_rows += f"""
|
||||
<tr>
|
||||
<td><strong>{esc(item.get('reference') or 'Realizado')}</strong></td>
|
||||
<td>{money_html(item.get('amount') or 0)}</td>
|
||||
<td>{esc(str(item.get('realised_at') or '')[:10])}</td>
|
||||
<td>{f'<a class="btn btn-sm btn-outline-primary" href="/opportunities/{esc(item.get("opportunity_id"))}">Abrir</a>' if item.get('opportunity_id') else '—'}</td>
|
||||
</tr>
|
||||
"""
|
||||
if not realised_rows:
|
||||
realised_rows = '<tr><td colspan="4" class="text-center text-secondary py-4">Sem valor realizado para esta métrica no mês selecionado.</td></tr>'
|
||||
|
||||
opportunity_rows = ""
|
||||
future_valued = [i for i in forecast["items"] if i.get("forecast_class") in {"committed", "probable"} and _number(i.get("amount")) > 0]
|
||||
for item in future_valued[:80]:
|
||||
flags = []
|
||||
if item.get("has_conflict"):
|
||||
flags.append("conflito fiscal")
|
||||
if item.get("is_stale"):
|
||||
flags.append("inativa >30d")
|
||||
if item.get("overdue_tasks"):
|
||||
flags.append(f"{item['overdue_tasks']} task(s) vencida(s)")
|
||||
sample = item.get("probability_sample") or {}
|
||||
sample_hint = ""
|
||||
if item.get("probability_source") == "historical_blended":
|
||||
sample_hint = f" · {int(sample.get('won') or 0)}/{int(sample.get('resolved') or 0)} ganhas"
|
||||
probability_cell = (
|
||||
'<strong>100%</strong><div class="small text-secondary">receita comprometida</div>'
|
||||
if item.get("forecast_class") == "committed"
|
||||
else f"<strong>{_pct(item.get('effective_probability'))}</strong><div class=\"small text-secondary\">fase {_pct(item.get('probability'))} × atividade {esc(item.get('activity_factor'))}{esc(sample_hint)}</div>"
|
||||
)
|
||||
expected_value = item.get("amount") if item.get("forecast_class") == "committed" else item.get("weighted_amount")
|
||||
opportunity_rows += f"""
|
||||
<tr>
|
||||
<td><a href="/opportunities/{esc(item.get('id'))}"><strong>{esc(item.get('customer_name') or item.get('title') or 'Oportunidade')}</strong></a><div class="small text-secondary">{esc(item.get('title') or '')}</div></td>
|
||||
<td>{esc(stage_label(item.get('stage')))}<div class="mt-1">{_class_badge(item.get('forecast_class'))}</div></td>
|
||||
<td>{money_html(item.get('amount') or 0)}<div class="small text-secondary">{esc(item.get('value_source'))}</div></td>
|
||||
<td>{probability_cell}</td>
|
||||
<td><strong>{money_html(expected_value or 0)}</strong></td>
|
||||
<td>{esc(str(item.get('expected_date') or '')[:10])}</td>
|
||||
<td>{esc(', '.join(flags) or '—')}</td>
|
||||
</tr>
|
||||
"""
|
||||
if not opportunity_rows:
|
||||
opportunity_rows = '<tr><td colspan="7" class="text-center text-secondary py-5">Sem oportunidades futuras valorizadas.</td></tr>'
|
||||
|
||||
stage_rows = "".join(
|
||||
f"<tr><td>{esc(stage_label(row.get('stage')))}</td><td>{esc(row.get('count'))}</td><td>{esc(row.get('valued'))}</td><td>{money_html(row.get('gross') or 0)}</td><td>{money_html(row.get('weighted') or 0)}</td></tr>"
|
||||
for row in forecast.get("stage_summary", [])
|
||||
)
|
||||
|
||||
zero_value_items = [i for i in forecast["items"] if i.get("forecast_class") in {"committed", "probable"} and _number(i.get("amount")) <= 0]
|
||||
zero_rows = "".join(
|
||||
f'<tr><td><a href="/opportunities/{esc(item.get("id"))}"><strong>{esc(item.get("customer_name") or item.get("title") or "Oportunidade")}</strong></a><div class="small text-secondary">{esc(item.get("title") or "")}</div></td><td>{esc(stage_label(item.get("stage")))}</td><td>{esc(str(item.get("updated_at") or "")[:10])}</td><td><a class="btn btn-sm btn-outline-primary" href="/opportunities/{esc(item.get("id"))}">Valorizar</a></td></tr>'
|
||||
for item in zero_value_items[:30]
|
||||
) or '<tr><td colspan="4" class="text-center text-secondary py-4">Todas as oportunidades futuras têm valor.</td></tr>'
|
||||
|
||||
scenarios = management["scenarios"]
|
||||
body = f"""
|
||||
<style>
|
||||
.cf-target-progress {{display:flex;height:22px;border-radius:999px;overflow:hidden;background:#e9ecef}}
|
||||
.cf-target-segment {{min-width:0;transition:width .2s ease}}
|
||||
.cf-target-realised {{background:#198754}} .cf-target-committed {{background:#0d6efd}}
|
||||
.cf-target-probable {{background:#ffc107}} .cf-target-missing {{background:#e9ecef}}
|
||||
.cf-dot {{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:5px}}
|
||||
.cf-dot-realised {{background:#198754}} .cf-dot-committed {{background:#0d6efd}}
|
||||
.cf-dot-probable {{background:#ffc107}} .cf-dot-missing {{background:#ced4da}}
|
||||
</style>
|
||||
|
||||
<div class="alert alert-info">Dashboard de gestão comercial. Separa o que já conta para a meta, o que está comprometido e o que ainda depende de conversão. Não representa tesouraria nem substitui validação contabilística.</div>
|
||||
|
||||
<section class="card cf-card mb-3"><div class="card-body p-4">
|
||||
<form method="post" action="/forecast/target" class="row g-3 align-items-end">
|
||||
<div class="col-md-3"><label class="form-label fw-bold">Mês da meta</label><input class="form-control" type="month" name="month" value="{esc(month_value)}" required></div>
|
||||
<div class="col-md-4"><label class="form-label fw-bold">Métrica</label><select class="form-select" name="metric">{metric_options}</select></div>
|
||||
<div class="col-md-3"><label class="form-label fw-bold">Meta (€)</label><input class="form-control" name="target_amount" inputmode="decimal" value="{esc(f'{target_amount:.2f}'.replace('.', ','))}" placeholder="10000,00"></div>
|
||||
<div class="col-md-2"><button class="btn btn-primary w-100" type="submit">Guardar meta</button></div>
|
||||
</form>
|
||||
<div class="small text-secondary mt-2">Período: {esc(period['month_start'])} a {esc(period['month_end'])} · critério: {esc(management['metric_label'])}</div>
|
||||
</div></section>
|
||||
|
||||
{_status_alert(management['status'])}
|
||||
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('Meta mensal', money_html(target_amount), '/forecast', management['metric_label'], 'bi-bullseye')}
|
||||
{kpi_card('Realizado', money_html(month_data['realised']), '/forecast', f"{summary['realised_count']} registo(s) no mês", 'bi-check2-circle', 'cf-kpi-tone-green')}
|
||||
{kpi_card('Comprometido', money_html(month_data['committed']), '/forecast', f"{month_data['committed_count']} oportunidade(s) até ao fim do mês", 'bi-lock')}
|
||||
{kpi_card('Pipeline provável', money_html(month_data['probable']), '/forecast', f"{month_data['probable_count']} oportunidade(s) ponderadas", 'bi-graph-up-arrow')}
|
||||
{kpi_card('Previsão total', money_html(month_data['forecast_total']), '/forecast', f"cumprimento {_pct(management['attainment'])}", 'bi-speedometer2')}
|
||||
{kpi_card('Desvio', money_html(management['gap']), '/forecast', 'falta para suportar a meta' if management['gap'] else f"excedente {money_html(management['surplus'])}", 'bi-exclamation-triangle', 'cf-kpi-tone-red' if management['gap'] else 'cf-kpi-tone-green')}
|
||||
</section>
|
||||
|
||||
<section class="card cf-card mb-3"><div class="card-body p-4">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2 mb-3"><div><h2 class="cf-section-title mb-1">Progresso da meta</h2><div class="small text-secondary">Sem dupla contagem entre realizado, comprometido e provável.</div></div><strong>{_pct(management['attainment'])}</strong></div>
|
||||
{progress}
|
||||
</div></section>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title">Até ao fim do mês</h2><div class="display-6 fw-bold">{money_html(month_data['forecast_total'])}</div><div class="small text-secondary">Realizado {money_html(month_data['realised'])} · futuro adicional {money_html(month_data['future_total'])}</div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title">Próximos 30 dias adicionais</h2><div class="display-6 fw-bold">{money_html(next30['future_total'])}</div><div class="small text-secondary">Até {esc(period['next_30_end'])}; não inclui o realizado do mês.</div></div></section></div>
|
||||
</div>
|
||||
|
||||
<section class="card cf-card mb-3"><div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between gap-3 flex-wrap">
|
||||
<div><h2 class="cf-section-title mb-1">Capacidade de recuperação</h2><div class="small text-secondary">Pagamentos já existentes previstos após o fim do mês que podem ser acelerados. Não entram na previsão base.</div></div>
|
||||
<div class="text-end"><div class="h3 fw-bold mb-0">{money_html(recoverable.get('weighted') or 0)}</div><div class="small text-secondary">{esc(recoverable.get('count') or 0)} pagamento(s) · bruto {money_html(recoverable.get('gross') or 0)}</div></div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-4"><div class="cf-soft-box"><span class="small text-secondary">Previsão com aceleração</span><strong class="d-block h4 mb-0">{money_html(recoverable.get('accelerated_total') or month_data['forecast_total'])}</strong></div></div>
|
||||
<div class="col-md-4"><div class="cf-soft-box"><span class="small text-secondary">Cumprimento acelerado</span><strong class="d-block h4 mb-0">{_pct(recoverable.get('accelerated_attainment'))}</strong></div></div>
|
||||
<div class="col-md-4"><div class="cf-soft-box"><span class="small text-secondary">Desvio residual</span><strong class="d-block h4 mb-0">{money_html(recoverable.get('residual_gap') or 0)}</strong></div></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-7"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-2">Diagnóstico do desvio</h2>{diagnostics_html}</div></section></div>
|
||||
<div class="col-xl-5"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Cenários até ao fim do mês</h2><table class="table cf-table"><tbody><tr><th>Conservador</th><td class="text-end">{money_html(scenarios['conservative'])}</td></tr><tr><th>Provável</th><td class="text-end fw-bold">{money_html(scenarios['probable'])}</td></tr><tr><th>Com aceleração</th><td class="text-end">{money_html(scenarios['optimistic'])}</td></tr><tr><th>Potencial máximo conhecido</th><td class="text-end">{money_html(scenarios.get('maximum_known') or scenarios['optimistic'])}</td></tr></tbody></table><hr><div class="small text-secondary">Novo pipeline necessário</div><div class="h3 fw-bold">{money_html(management['new_pipeline_required'])}</div><div class="small text-secondary">Conversão usada {_pct(management['new_pipeline_conversion'])} · aproximadamente {esc(management['new_opportunities_required'])} nova(s) oportunidade(s), quando existe valor médio suficiente.</div></div></section></div>
|
||||
</div>
|
||||
|
||||
<section class="card cf-card mb-3"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Ações de maior impacto</h2><div class="small text-secondary">O que executar hoje para proteger ou recuperar a meta.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Grupo</th><th>Oportunidade</th><th>Fase</th><th>Valor</th><th>Ação recomendada</th><th>Impacto</th></tr></thead><tbody>{actions_rows}</tbody></table></div></div></section>
|
||||
|
||||
<section class="card cf-card mb-3"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Realizado no mês</h2><div class="small text-secondary">Registos que já contam para a métrica selecionada; não voltam a ser somados no pipeline futuro.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Referência</th><th>Valor</th><th>Data</th><th></th></tr></thead><tbody>{realised_rows}</tbody></table></div></div></section>
|
||||
|
||||
<section class="card cf-card mb-3"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Oportunidades que suportam a previsão futura</h2><div class="small text-secondary">Realizado no mês é apresentado separadamente; esta tabela mostra apenas valor adicional comprometido ou provável.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Oportunidade</th><th>Fase</th><th>Valor</th><th>Probabilidade</th><th>Valor esperado</th><th>Data</th><th>Alertas</th></tr></thead><tbody>{opportunity_rows}</tbody></table></div></div></section>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Funil por fase</h2><div class="small text-secondary">Quantidade, cobertura e valor.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Fase</th><th>Total</th><th>Com valor</th><th>Bruto</th><th>Ponderado</th></tr></thead><tbody>{stage_rows}</tbody></table></div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Oportunidades por valorizar</h2><div class="small text-secondary">{esc(summary['unvalued_opportunities'])} de {esc(summary['opportunities'])} sem valor · cobertura {_pct(summary['value_coverage'])} · qualidade {_pct(summary['quality_score'])}.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Oportunidade</th><th>Fase</th><th>Atualizada</th><th></th></tr></thead><tbody>{zero_rows}</tbody></table></div></div></section></div>
|
||||
</div>
|
||||
"""
|
||||
return layout("Meta e desempenho comercial", "Acompanhar vendas e decidir quando mudar a abordagem", body, "forecast")
|
||||
@@ -4,6 +4,8 @@ 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
|
||||
from sqlalchemy import text
|
||||
from app.db import engine
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
@@ -203,7 +205,7 @@ async def system_health_page():
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
@router.get("/configuracoes", response_class=HTMLResponse)
|
||||
async def settings_page():
|
||||
return RedirectResponse("/system", status_code=303)
|
||||
return RedirectResponse("/settings/workflow", status_code=303)
|
||||
|
||||
|
||||
@router.get("/system/health", response_class=HTMLResponse)
|
||||
@@ -244,7 +246,7 @@ async def system_health_operational_page():
|
||||
doc_rows = '<tr><td colspan="2" class="text-secondary py-4">Sem documentos.</td></tr>'
|
||||
|
||||
db_ok = bool((health.get("database") or {}).get("ok"))
|
||||
critical_count = (0 if db_ok else 1) + m("outbox_processing_stale") + m("outbox_stale") + m("outbox_blocked_or_failed")
|
||||
critical_count = (0 if db_ok else 1) + m("outbox_processing_stale") + m("outbox_stale") + m("outbox_blocked_or_failed") + m("chatwoot_incoming_pending")
|
||||
warning_count = m("ambiguous_opportunity_tasks") + m("open_opportunities_without_fiscal_customer") + m("active_incomplete_fiscal_customers") + m("products_missing_external_code")
|
||||
if critical_count:
|
||||
production_state = "Crítico"
|
||||
@@ -288,6 +290,10 @@ async def system_health_operational_page():
|
||||
{kpi_card('Último webhook Chatwoot', esc('—' if not m('seconds_since_last_chatwoot_webhook') else str(m('seconds_since_last_chatwoot_webhook')) + 's'), '/events', f"eventos: {esc(m('chatwoot_events_total'))}", 'bi-chat-dots')}
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid mt-3">
|
||||
{kpi_card('Chatwoot inbound pendente', esc(m('chatwoot_incoming_pending')), '/events', f"inbound 24h: {esc(m('chatwoot_incoming_24h'))}", 'bi-inbox', 'cf-kpi-tone-red' if m('chatwoot_incoming_pending') else 'cf-kpi-tone-green')}
|
||||
</section>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Timers systemd</h2><div class="small text-secondary">Estado best-effort dos timers da outbox.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Integração</th><th>Unidade</th><th>Estado</th></tr></thead><tbody>{timer_rows}</tbody></table></div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Outbox por sistema</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Sistema</th><th>Estados</th></tr></thead><tbody>{outbox_rows}</tbody></table></div></div></section></div>
|
||||
@@ -298,3 +304,127 @@ async def system_health_operational_page():
|
||||
return layout("Saúde operacional", "Base de dados, integrações, timers e contadores", body, "system")
|
||||
|
||||
|
||||
|
||||
@router.get("/settings/workflow", response_class=HTMLResponse)
|
||||
@router.get("/configuracao/fluxo-operacional", response_class=HTMLResponse)
|
||||
async def workflow_settings_page():
|
||||
from app.domain.opportunity_flow import load_company_profile
|
||||
|
||||
profile = load_company_profile("blif")
|
||||
stage_rows = "".join(
|
||||
f"<tr><td><code>{esc(item.get('code'))}</code></td><td>{esc(item.get('label'))}</td></tr>"
|
||||
for item in profile.commercial_stages
|
||||
if isinstance(item, dict)
|
||||
) or '<tr><td colspan="2" class="text-secondary py-4">Sem fases configuradas.</td></tr>'
|
||||
payment_rows = "".join(f"<tr><td><code>{esc(k)}</code></td><td>{esc(v)}</td></tr>" for k, v in profile.payment_terms.items())
|
||||
delivery_rows = "".join(f"<tr><td><code>{esc(k)}</code></td><td>{esc(v)}</td></tr>" for k, v in profile.delivery_terms.items())
|
||||
action_rows = "".join(
|
||||
f"<tr><td><code>{esc(code)}</code></td><td>{esc((cfg or {}).get('label') if isinstance(cfg, dict) else cfg)}</td><td class='text-secondary small'>{esc((cfg or {}).get('description') if isinstance(cfg, dict) else '')}</td></tr>"
|
||||
for code, cfg in profile.actions.items()
|
||||
)
|
||||
right_cards = profile.ui.get("opportunity_cards", {}).get("right", []) if isinstance(profile.ui, dict) else []
|
||||
left_cards = profile.ui.get("opportunity_cards", {}).get("left", []) if isinstance(profile.ui, dict) else []
|
||||
body = f'''
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/system">← Voltar ao sistema</a>
|
||||
<section class="card cf-card mb-3"><div class="card-body p-4">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-3 align-items-start">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-2">Fluxo operacional</h1>
|
||||
<div class="text-secondary">Configuração controlada do perfil ativo da empresa. As regras críticas continuam protegidas no backend.</div><div class="mt-3"><a class="btn btn-outline-primary btn-sm" href="/settings/workflow/audit">Abrir auditor de coerência</a></div>
|
||||
</div>
|
||||
<div class="text-end"><span class="cf-chip cf-chip-blue">{esc(profile.name)}</span><div class="small text-secondary mt-2">{esc(profile.version)}</div></div>
|
||||
</div>
|
||||
</div></section>
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Defaults</h2><dl class="row mb-0"><dt class="col-5">Pagamento</dt><dd class="col-7">{esc(profile.defaults.get('payment_terms') or '—')}</dd><dt class="col-5">Entrega</dt><dd class="col-7">{esc(profile.defaults.get('delivery_terms') or '—')}</dd><dt class="col-5">Follow-up</dt><dd class="col-7">{esc(profile.defaults.get('follow_up_delay_days') or '—')} dias</dd></dl></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Cards da oportunidade</h2><div class="small text-secondary fw-bold">Coluna operação</div><div class="mb-2">{esc(' → '.join(map(str, right_cards)) or '—')}</div><div class="small text-secondary fw-bold">Coluna contexto</div><div>{esc(' → '.join(map(str, left_cards)) or '—')}</div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Condições de pagamento</h2><table class="table cf-table"><tbody>{payment_rows}</tbody></table></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Tipos de entrega</h2><table class="table cf-table"><tbody>{delivery_rows}</tbody></table></div></section></div>
|
||||
<div class="col-12"><section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Fases comerciais</h2><table class="table cf-table"><thead><tr><th>Código</th><th>Nome</th></tr></thead><tbody>{stage_rows}</tbody></table></div></section></div>
|
||||
<div class="col-12"><section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Ações do motor</h2><table class="table cf-table"><thead><tr><th>Código</th><th>Nome</th><th>Descrição</th></tr></thead><tbody>{action_rows}</tbody></table></div></section></div>
|
||||
</div>
|
||||
'''
|
||||
return layout("Fluxo operacional", "Perfil de empresa e defaults do workflow", body, "settings")
|
||||
|
||||
|
||||
@router.get("/settings/workflow/audit", response_class=HTMLResponse)
|
||||
@router.get("/admin/workflow/audit", response_class=HTMLResponse)
|
||||
async def workflow_audit_page():
|
||||
"""Lightweight coherence audit for workflow/operator UX regressions."""
|
||||
findings = []
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("""
|
||||
SELECT
|
||||
o.id::text,
|
||||
o.title,
|
||||
o.stage,
|
||||
o.customer_name,
|
||||
o.updated_at,
|
||||
COUNT(*) FILTER (WHERE t.status = 'pending')::int AS pending_tasks,
|
||||
COUNT(*) FILTER (WHERE t.status = 'pending' AND t.action_code = 'CONFIRM_PAYMENT')::int AS pending_confirm_payment,
|
||||
COUNT(*) FILTER (WHERE t.status = 'pending' AND t.action_code = 'SEND_INVOICE')::int AS pending_send_invoice,
|
||||
COUNT(*) FILTER (WHERE d.document_kind = 'invoice')::int AS invoices,
|
||||
COUNT(*) FILTER (WHERE d.document_kind = 'quotation')::int AS quotes,
|
||||
COUNT(*) FILTER (WHERE ol.system = 'clientflow' AND ol.external_type = 'payment' AND ol.status = 'confirmed')::int AS payments_confirmed,
|
||||
COUNT(*) FILTER (WHERE lower(coalesce(t.note,'')) LIKE '%fatura por emitir%')::int AS stale_invoice_note,
|
||||
COUNT(*) FILTER (WHERE lower(coalesce(t.note,'')) LIKE '%pró-forma%' OR lower(coalesce(t.action,'')) LIKE '%pró-forma%')::int AS visible_proforma_task
|
||||
FROM opportunities o
|
||||
LEFT JOIN tasks t ON t.opportunity_id = o.id
|
||||
LEFT JOIN commercial_documents d ON d.opportunity_id = o.id
|
||||
LEFT JOIN operation_links ol ON ol.opportunity_id = o.id
|
||||
WHERE coalesce(o.status, 'open') <> 'closed'
|
||||
GROUP BY o.id, o.title, o.stage, o.customer_name, o.updated_at
|
||||
ORDER BY o.updated_at DESC NULLS LAST
|
||||
LIMIT 300
|
||||
""")).mappings().all()
|
||||
except Exception as exc:
|
||||
rows = []
|
||||
findings.append({"severity": "alto", "code": "audit_query_failed", "title": "Auditoria indisponível", "detail": str(exc), "url": "/system"})
|
||||
|
||||
try:
|
||||
from app.jasmin_fiscal_sync_service import audit_jasmin_fiscal_gaps
|
||||
findings.extend(audit_jasmin_fiscal_gaps(limit=150))
|
||||
except Exception as exc:
|
||||
findings.append({"severity": "baixo", "code": "jasmin_fiscal_audit_unavailable", "title": "Auditoria Jasmin fiscal indisponível", "detail": str(exc), "url": "/settings/workflow/audit"})
|
||||
|
||||
for row in rows:
|
||||
url = f"/opportunities/{row.get('id')}"
|
||||
title = row.get("title") or row.get("customer_name") or row.get("id")
|
||||
if int(row.get("payments_confirmed") or 0) > 0 and int(row.get("pending_confirm_payment") or 0) > 0:
|
||||
findings.append({"severity": "alto", "code": "payment_confirmed_but_confirm_task", "title": title, "detail": "Pagamento confirmado mas ainda existe task pendente de confirmar pagamento.", "url": url})
|
||||
if int(row.get("invoices") or 0) > 0 and int(row.get("stale_invoice_note") or 0) > 0:
|
||||
findings.append({"severity": "médio", "code": "invoice_exists_but_task_says_to_issue", "title": title, "detail": "Fatura associada mas alguma task ainda diz 'fatura por emitir'.", "url": url})
|
||||
if int(row.get("visible_proforma_task") or 0) > 0:
|
||||
findings.append({"severity": "baixo", "code": "legacy_proforma_word_visible", "title": title, "detail": "Texto histórico ainda contém 'pró-forma'; normalizar para orçamento para pagamento.", "url": url})
|
||||
if int(row.get("payments_confirmed") or 0) > 0 and int(row.get("invoices") or 0) == 0 and str(row.get("stage") or "").upper() not in {"PAYMENT_CONFIRMED", "WAITING_PAYMENT", "REVIEW"}:
|
||||
findings.append({"severity": "médio", "code": "payment_confirmed_without_invoice", "title": title, "detail": "Pagamento confirmado sem fatura associada; verificar próxima ação.", "url": url})
|
||||
|
||||
severity_order = {"alto": 0, "médio": 1, "baixo": 2}
|
||||
findings.sort(key=lambda f: (severity_order.get(str(f.get("severity")), 9), str(f.get("title") or "")))
|
||||
rows_html = ""
|
||||
for f in findings[:200]:
|
||||
sev = str(f.get("severity") or "baixo")
|
||||
chip = "cf-chip-red" if sev == "alto" else ("cf-chip-orange" if sev == "médio" else "cf-chip-gray")
|
||||
rows_html += f"""
|
||||
<tr>
|
||||
<td><span class="cf-chip {chip}">{esc(sev)}</span></td>
|
||||
<td><code>{esc(f.get('code'))}</code></td>
|
||||
<td><a class="cf-row-link" href="{esc(f.get('url') or '#')}">{esc(f.get('title') or 'Oportunidade')}</a><div class="small text-secondary">{esc(f.get('detail') or '')}</div></td>
|
||||
</tr>
|
||||
"""
|
||||
if not rows_html:
|
||||
rows_html = '<tr><td colspan="3" class="text-secondary py-4">Sem incoerências encontradas nos primeiros processos analisados.</td></tr>'
|
||||
body = f'''
|
||||
<a class="cf-row-link d-inline-flex mb-3" href="/settings/workflow">← Voltar ao fluxo operacional</a>
|
||||
<section class="card cf-card mb-3"><div class="card-body p-4">
|
||||
<h1 class="h3 fw-bold mb-2">Auditor de coerência do fluxo</h1>
|
||||
<div class="text-secondary">Deteta sinais de regressão entre pagamentos, faturas, tarefas antigas e textos legados. Esta primeira versão é read-only.</div>
|
||||
<div class="mt-3"><span class="cf-chip cf-chip-blue">{len(findings)} achado(s)</span></div>
|
||||
</div></section>
|
||||
<section class="card cf-card"><div class="card-body p-0">
|
||||
<div class="p-3 border-bottom"><h2 class="cf-section-title">Achados</h2><div class="small text-secondary">Prioriza alto/médio antes de operar novas ações financeiras.</div></div>
|
||||
<div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Severidade</th><th>Código</th><th>Processo</th></tr></thead><tbody>{rows_html}</tbody></table></div>
|
||||
</div></section>
|
||||
'''
|
||||
return layout("Auditor de fluxo", "Coerência operacional", body, "settings")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user