Release v4928.1.4.2 stable

This commit is contained in:
2026-06-09 22:55:58 +01:00
commit 6445044ac6
280 changed files with 41775 additions and 0 deletions

5
app/admin_ui/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Admin UI package for the incremental ClientFlow v4 refactor.
The legacy `app.admin_dashboard` module still owns the routes for now. New UI
partials and pages should be moved here gradually without changing public URLs.
"""

116
app/admin_ui/components.py Normal file
View File

@@ -0,0 +1,116 @@
"""Reusable HTML helpers for the admin UI refactor.
Keep these helpers dependency-light so pages can migrate from
`admin_dashboard.py` gradually.
"""
from __future__ import annotations
import html
from datetime import datetime
from decimal import Decimal
from typing import Any
def esc(value: Any) -> str:
return html.escape("" if value is None else str(value))
def money(value: Any, currency: str = "EUR") -> str:
try:
amount = Decimal(str(value or "0"))
return f"{amount:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
except Exception:
return f"{esc(value)} {esc(currency)}"
def fmt_dt(value: Any) -> str:
if not value:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M")
return esc(value)
def status_chip(status: str | None) -> str:
status = (status or "unknown").lower()
css = {
"pending": "cf-chip-orange",
"processing": "cf-chip-purple",
"sent": "cf-chip-green",
"failed": "cf-chip-red",
"blocked": "cf-chip-red",
"dry_run": "cf-chip-gray",
"ignored": "cf-chip-gray",
"cancelled": "cf-chip-gray",
"created": "cf-chip-blue",
"issued": "cf-chip-green",
"converted": "cf-chip-green",
}.get(status, "cf-chip-gray")
return f'<span class="cf-chip {css}">{esc(status)}</span>'
def kpi_icon_for(label: str) -> str:
text = (label or "").lower()
if "oportun" in text or "pipeline" in text:
return "bi-funnel"
if "valor" in text or "pagamento" in text or "finance" in text or "fatura" in text:
return "bi-currency-euro"
if "document" in text or "orçamento" in text or "orcamento" in text:
return "bi-file-earmark-text"
if "task" in text or "tarefa" in text or "pendente" in text:
return "bi-list-check"
if "email" in text or "comunica" in text or ("cliente" in text and "sem" in text):
return "bi-envelope"
if "erro" in text or "invál" in text or "inval" in text or "bloque" in text:
return "bi-exclamation-triangle"
if "envio" in text or "packlink" in text or "opera" in text or "prepar" in text:
return "bi-truck"
if "cliente" in text:
return "bi-person-vcard"
if "produto" in text or "artigo" in text or "categoria" in text or "jasmin" in text:
return "bi-box-seam"
if "integra" in text or "sistema" in text or "ambiente" in text or "estado" in text:
return "bi-puzzle"
if "retry" in text or "retries" in text:
return "bi-arrow-clockwise"
return "bi-grid"
def kpi_tone_for(label: str, hint: str = "") -> str:
text = f"{label or ''} {hint or ''}".lower()
if "erro" in text or "falh" in text or "invál" in text or "inval" in text:
return "cf-kpi-tone-red"
if "pagamento" in text or "valor" in text or "finance" in text or "" in text:
return "cf-kpi-tone-green"
if "envio" in text or "packlink" in text or "pendente" in text or "aguarda" in text or "incompleto" in text or "bloque" in text:
return "cf-kpi-tone-orange"
if "cliente" in text or "comunica" in text or "email" in text:
return "cf-kpi-tone-purple"
return ""
def kpi_card(label: str, value: object, href: str | None = None, hint: str = "", icon: str | None = None, tone: str | None = None) -> str:
tag = "a" if href else "div"
href_attr = f' href="{esc(href)}"' if href else ""
reset_class = " text-reset text-decoration-none" if href else ""
icon_class = icon or kpi_icon_for(label)
tone_class = tone or kpi_tone_for(label, hint)
return f"""
<{tag} class="cf-card cf-kpi{reset_class} {esc(tone_class)}"{href_attr}>
<span class="cf-kpi-icon" aria-hidden="true"><i class="bi {esc(icon_class)}"></i></span>
<span>{esc(label)}</span>
<strong>{esc(value)}</strong>
<small>{esc(hint)}</small>
</{tag}>
"""
def alert_box(title: str, detail: str, tone: str = "warning") -> str:
css = {
"danger": "alert-danger",
"warning": "alert-warning",
"info": "alert-info",
"success": "alert-success",
}.get(tone, "alert-warning")
return f'<div class="alert {css} border-0"><strong>{esc(title)}</strong><div>{esc(detail)}</div></div>'

353
app/admin_ui/guidance.py Normal file
View File

@@ -0,0 +1,353 @@
"""Small operator-facing UI helpers for guided operations.
v4.8.2 intentionally keeps business rules unchanged. Prontidão is shown as UI guidance only. These helpers only turn
existing opportunity/task/outbox data into clearer labels, blockers and
checklists for the operator.
"""
from __future__ import annotations
import html
from typing import Any, Iterable
def esc(value: Any) -> str:
return html.escape("" if value is None else str(value))
def _present(value: Any) -> bool:
return bool(str(value or "").strip())
def _field(data: dict[str, Any] | None, *keys: str) -> str:
data = data or {}
for key in keys:
value = data.get(key)
if _present(value):
return str(value).strip()
return ""
DOCUMENT_ACTION_CODES = {
"SEND_PROFORMA",
"SEND_INVOICE",
}
# Pagamento e envio têm checklists próprias. Não devem bloquear uma oportunidade
# antiga/reconstruída só porque faltam campos fiscais no ClientFlow depois de o
# documento oficial já ter sido emitido.
FULFILMENT_ACTION_CODES = {
"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT",
"PREPARE_ORDER",
"CREATE_SHIPMENT",
}
DOCUMENT_STAGES = {
"PROFORMA_REQUESTED",
"PROFORMA_SENT",
"INVOICE_REQUESTED",
"INVOICE_SENT",
"WAITING_PAYMENT",
"PAYMENT_CONFIRMED",
"ORDER_PREPARATION",
"READY_TO_SHIP",
"INVOICED",
"SHIPMENT_CREATED",
"SHIPPED",
"TRACKING_SENT",
"DELIVERED",
}
SHIPMENT_ACTION_CODES = {"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT", "PREPARE_ORDER", "CREATE_SHIPMENT"}
SHIPMENT_STAGES = {"PAYMENT_CONFIRMED", "ORDER_PREPARATION", "READY_TO_SHIP", "SHIPMENT_CREATED", "SHIPPED", "TRACKING_SENT", "DELIVERED"}
def normalized_action_code(value: Any) -> str:
return str(value or "").strip().upper()
def action_requires_fiscal_customer(action_code: Any) -> bool:
return normalized_action_code(action_code) in DOCUMENT_ACTION_CODES
def action_requires_shipment_readiness(action_code: Any) -> bool:
return normalized_action_code(action_code) in SHIPMENT_ACTION_CODES
def stage_requires_fiscal_customer(stage: Any) -> bool:
return str(stage or "").strip().upper() in DOCUMENT_STAGES
def stage_requires_shipment_readiness(stage: Any) -> bool:
return str(stage or "").strip().upper() in SHIPMENT_STAGES
def item_requires_fiscal_customer(item: dict[str, Any] | None) -> bool:
item = item or {}
return action_requires_fiscal_customer(item.get("action_code"))
def item_requires_shipment_readiness(item: dict[str, Any] | None) -> bool:
item = item or {}
return action_requires_shipment_readiness(item.get("action_code"))
def fiscal_customer_missing_fields(customer: dict[str, Any] | None) -> list[str]:
"""Minimum fields needed before fiscal documents should be issued."""
if not customer:
return ["cliente fiscal associado"]
missing: list[str] = []
if not _field(customer, "name", "linked_customer_name"):
missing.append("nome fiscal")
if not _field(customer, "tax_id", "linked_customer_tax_id"):
missing.append("NIF")
if not _field(customer, "email", "linked_customer_email", "customer_email"):
missing.append("email de faturação")
if not _field(customer, "street_name"):
missing.append("morada fiscal")
if not _field(customer, "postal_zone"):
missing.append("código postal")
if not _field(customer, "city_name"):
missing.append("localidade")
return missing
def shipment_missing_fields(customer: dict[str, Any] | None, opportunity: dict[str, Any] | None = None) -> list[str]:
missing: list[str] = []
if not customer:
return ["cliente fiscal associado", "morada de entrega", "telefone"]
if not _field(customer, "street_name"):
missing.append("morada de entrega")
if not _field(customer, "postal_zone"):
missing.append("código postal de entrega")
if not _field(customer, "city_name"):
missing.append("localidade de entrega")
if not (_field(customer, "phone") or _field(opportunity or {}, "customer_phone")):
missing.append("telefone")
return missing
def opportunity_context_customer(opportunity: dict[str, Any], linked_customer: dict[str, Any] | None = None) -> dict[str, Any] | None:
if linked_customer:
return linked_customer
if opportunity.get("linked_customer_id") or opportunity.get("linked_customer_name"):
return {
"id": opportunity.get("linked_customer_id"),
"name": opportunity.get("linked_customer_name"),
"email": opportunity.get("linked_customer_email"),
"tax_id": opportunity.get("linked_customer_tax_id"),
"street_name": opportunity.get("linked_customer_street_name"),
"postal_zone": opportunity.get("linked_customer_postal_zone"),
"city_name": opportunity.get("linked_customer_city_name"),
"phone": opportunity.get("linked_customer_phone"),
}
return None
def opportunity_blockers(
opportunity: dict[str, Any],
linked_customer: dict[str, Any] | None = None,
*,
action_code: Any = None,
) -> list[str]:
"""Return only blockers that matter for the current journey stage.
Missing fiscal data is not automatically a blocker at the first contact. It
becomes a current blocker only when the next action/stage needs fiscal
documents, payment or fulfilment.
"""
customer = opportunity_context_customer(opportunity, linked_customer)
blockers: list[str] = []
linking_status = str(opportunity.get("opportunity_linking_status") or opportunity.get("linking_status") or "").lower()
if linking_status == "ambiguous":
blockers.append("Associação de oportunidade por confirmar")
needs_fiscal = action_requires_fiscal_customer(action_code) if action_code else stage_requires_fiscal_customer(opportunity.get("stage"))
if needs_fiscal:
if not customer:
blockers.append("Cliente fiscal por associar")
else:
for item in fiscal_customer_missing_fields(customer):
blockers.append(f"Cliente fiscal sem {item}")
needs_product = needs_fiscal or str(opportunity.get("stage") or "").upper() in {"QUOTE_REQUESTED", "QUOTE_SENT"}
if needs_product and not _field(opportunity, "product_interest", "title"):
blockers.append("Produto/interesse por definir")
return blockers
def work_item_fiscal_customer(item: dict[str, Any]) -> dict[str, Any] | None:
"""Return the fiscal customer carried by an Operations item, if any.
The work queue must not infer a fiscal customer from Chatwoot contact_id. It
only uses the opportunity/customer links already resolved by the backend.
"""
item = item or {}
if not _field(item, "fiscal_customer_name"):
return None
return {
"name": item.get("fiscal_customer_name"),
"email": item.get("fiscal_customer_email"),
"tax_id": item.get("fiscal_customer_tax_id"),
"street_name": item.get("fiscal_customer_street_name"),
"postal_zone": item.get("fiscal_customer_postal_zone"),
"city_name": item.get("fiscal_customer_city_name"),
}
def work_item_blockers(item: dict[str, Any]) -> list[str]:
"""Compact blockers for Operations cards, based on the next action."""
blockers: list[str] = []
linking_status = str(item.get("opportunity_linking_status") or "").lower()
if linking_status == "ambiguous":
blockers.append("Associação de oportunidade por confirmar")
if item_requires_fiscal_customer(item):
customer = work_item_fiscal_customer(item)
if not customer:
blockers.append("Cliente fiscal por associar")
else:
for missing in fiscal_customer_missing_fields(customer):
blockers.append(f"Cliente fiscal sem {missing}")
return blockers
def blocker_alert_html(blockers: Iterable[str], *, empty_text: str = "Sem bloqueios críticos visíveis.") -> str:
blockers = [str(item) for item in blockers if str(item or "").strip()]
if not blockers:
return f'<div class="alert alert-success border-0 mb-0"><strong>{esc(empty_text)}</strong></div>'
items = "".join(f"<li>{esc(item)}</li>" for item in blockers)
return f'<div class="alert alert-warning border-0 mb-0"><strong>Bloqueios atuais</strong><ul class="mb-0 mt-2">{items}</ul></div>'
def readiness_checklist_html(
*,
title: str,
missing: Iterable[str],
ok_text: str = "Pronto para avançar.",
blocked_text: str = "Ação bloqueada até corrigir os dados em falta.",
) -> str:
missing_items = [str(item) for item in missing if str(item or "").strip()]
if missing_items:
pills = "".join(f'<span class="cf-readiness-pill missing">⚠ {esc(item)}</span>' for item in missing_items)
state = f'<div class="small text-danger fw-bold mt-2">{esc(blocked_text)}</div>'
else:
pills = '<span class="cf-readiness-pill ok">✓ Dados mínimos completos</span>'
state = f'<div class="small text-success fw-bold mt-2">{esc(ok_text)}</div>'
return f'''
<section class="card cf-card cf-readiness-card">
<div class="card-body p-4">
<h2 class="cf-section-title mb-2">{esc(title)}</h2>
<div class="d-flex flex-wrap gap-2">{pills}</div>
{state}
</div>
</section>
'''
def fiscal_contact_panel_html(
*,
fiscal_customer: dict[str, Any] | None,
contact_name: Any = "",
contact_email: Any = "",
contact_phone: Any = "",
conversation_id: Any = "",
contact_id: Any = "",
customer_href: str = "",
) -> str:
customer = fiscal_customer or {}
customer_name = _field(customer, "name", "linked_customer_name") or "Cliente fiscal por associar"
tax_id = _field(customer, "tax_id", "linked_customer_tax_id") or ""
email = _field(customer, "email", "linked_customer_email") or ""
address_bits = [
_field(customer, "street_name"),
" ".join(bit for bit in [_field(customer, "postal_zone"), _field(customer, "city_name")] if bit),
]
address = " · ".join(bit for bit in address_bits if bit) or ""
contact_name = str(contact_name or "Contacto por confirmar")
contact_email = str(contact_email or "")
contact_phone = str(contact_phone or "")
conversation = str(conversation_id or "")
contact_ref = str(contact_id or "")
customer_button = f'<a class="btn btn-sm btn-outline-secondary mt-2" href="{esc(customer_href)}">Ver cliente fiscal</a>' if customer_href else ""
return f'''
<section class="card cf-card cf-fiscal-contact-panel">
<div class="card-body p-4">
<h2 class="cf-section-title mb-3">Cliente fiscal e contacto Chatwoot</h2>
<div class="cf-fiscal-contact-grid">
<div class="cf-fiscal-box fiscal">
<div class="cf-fiscal-label">Cliente fiscal</div>
<strong>{esc(customer_name)}</strong>
<div class="small text-secondary">NIF {esc(tax_id)}</div>
<div class="small text-secondary text-break">{esc(email)}</div>
<div class="small text-secondary text-break">{esc(address)}</div>
{customer_button}
</div>
<div class="cf-fiscal-box contact">
<div class="cf-fiscal-label">Contacto Chatwoot</div>
<strong>{esc(contact_name)}</strong>
<div class="small text-secondary text-break">{esc(contact_email)}</div>
<div class="small text-secondary">{esc(contact_phone)}</div>
<div class="small text-secondary">{esc('Conversa #' + conversation if conversation else 'Sem conversa ligada')}</div>
<div class="small text-secondary">{esc('Contacto #' + contact_ref if contact_ref else '')}</div>
</div>
</div>
</div>
</section>
'''
def fiscal_contact_inline_html(
*,
fiscal_name: Any = "",
contact_ref: Any = "",
conversation_id: Any = "",
show_fiscal: bool = True,
fiscal_required: bool = False,
) -> str:
contact = str(contact_ref or "").strip()
conversation = str(conversation_id or "").strip()
contact_bits = []
if contact:
contact_bits.append(f"contacto {contact}")
if conversation:
contact_bits.append(f"conversa #{conversation}")
contact_line = " · ".join(contact_bits) or "contacto por confirmar"
fiscal_value = str(fiscal_name or "").strip()
if not show_fiscal and not fiscal_required:
return f'''
<div class="cf-fiscal-mini compact">
<div><span>Contacto Chatwoot</span><strong>{esc(contact_line)}</strong></div>
</div>
'''
fiscal_label = fiscal_value or "Por associar"
return f'''
<div class="cf-fiscal-mini">
<div><span>Cliente fiscal</span><strong>{esc(fiscal_label)}</strong></div>
<div><span>Contacto Chatwoot</span><strong>{esc(contact_line)}</strong></div>
</div>
'''
def outbox_operator_message(item: dict[str, Any]) -> dict[str, str]:
target = str(item.get("target_system") or "integração").strip()
action = str(item.get("action_type") or "ação").strip()
error = str(item.get("last_error") or "").strip()
lower = error.casefold()
title = f"Falha em {target}.{action}" if error else f"Ação {target}.{action}"
probable = "Ver o detalhe técnico do erro antes de reprocessar."
fix_label = "Ver detalhe"
if "tax" in lower or "nif" in lower or "vat" in lower or "fiscal" in lower:
probable = "Cliente fiscal sem NIF válido ou dados fiscais incompletos."
fix_label = "Corrigir cliente"
elif "address" in lower or "morada" in lower or "postal" in lower or "city" in lower:
probable = "Morada fiscal ou morada de entrega incompleta."
fix_label = "Corrigir morada"
elif "product" in lower or "item" in lower or "sales_item" in lower or "artigo" in lower:
probable = "Produto sem código externo/Jasmin ou artigo inválido."
fix_label = "Corrigir produto"
elif "payment" in lower or "pagamento" in lower:
probable = "Pagamento ainda não validado ou estado financeiro inconsistente."
fix_label = "Ver pagamento"
elif "timeout" in lower or "connection" in lower or "tempor" in lower:
probable = "Falha temporária de ligação à integração externa."
fix_label = "Reprocessar depois"
return {"title": title, "probable": probable, "fix_label": fix_label, "technical": error}

35
app/admin_ui/htmx.py Normal file
View File

@@ -0,0 +1,35 @@
"""Small HTMX helpers shared by admin pages."""
from __future__ import annotations
from typing import Any
from fastapi import Request
from app.admin_ui.components import esc
def is_htmx_request(request: Request | None) -> bool:
if request is None:
return False
return str(request.headers.get("HX-Request") or "").lower() == "true"
def htmx_attrs(
url: str,
*,
target: str,
swap: str = "innerHTML",
push_url: str | bool | None = None,
indicator: str | None = None,
) -> str:
attrs = [
f'hx-get="{esc(url)}"',
f'hx-target="{esc(target)}"',
f'hx-swap="{esc(swap)}"',
]
if push_url is not None:
value = "true" if push_url is True else "false" if push_url is False else str(push_url)
attrs.append(f'hx-push-url="{esc(value)}"')
if indicator:
attrs.append(f'hx-indicator="{esc(indicator)}"')
return " ".join(attrs)

109
app/admin_ui/labels.py Normal file
View File

@@ -0,0 +1,109 @@
"""Centralized Portuguese labels for the admin UI.
v4.7.3 keeps business rules unchanged, but moves display labels out of the
route handlers so Operations, Tasks and Opportunities use the same wording.
"""
from __future__ import annotations
from typing import Any
ACTION_LABELS = {
"SEND_INFO": "Enviar informação",
"SEND_QUOTE": "Preparar orçamento",
"SEND_PROFORMA": "Emitir pró-forma",
"SEND_INVOICE": "Emitir fatura",
"CONFIRM_PAYMENT": "Confirmar pagamento",
"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT": "Confirmar pagamento",
"PREPARE_ORDER": "Preparar encomenda",
"CREATE_SHIPMENT": "Criar envio",
"REVIEW_MANUALLY": "Rever manualmente",
"ASSOCIATE_CUSTOMER": "Associar cliente",
"ASSOCIATE_OPPORTUNITY": "Associar oportunidade",
"MARK_NO_INTEREST": "Marcar sem interesse",
"REMOVE_FROM_LIST": "Remover da lista",
"SUPPORT": "Responder suporte",
"NO_ACTION": "Sem ação",
"IGNORE_SPAM": "Ignorar spam",
}
PRIMARY_ACTION_LABELS = {
"SEND_INFO": "Preparar resposta",
"SEND_QUOTE": "Preparar orçamento",
"SEND_PROFORMA": "Preparar pró-forma",
"SEND_INVOICE": "Emitir fatura",
"CONFIRM_PAYMENT": "Confirmar pagamento",
"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT": "Confirmar pagamento",
"PREPARE_ORDER": "Preparar encomenda",
"CREATE_SHIPMENT": "Criar envio",
"REVIEW_MANUALLY": "Rever mensagem",
"ASSOCIATE_CUSTOMER": "Associar cliente",
"ASSOCIATE_OPPORTUNITY": "Associar oportunidade",
"MARK_NO_INTEREST": "Marcar sem interesse",
"REMOVE_FROM_LIST": "Remover da lista",
"SUPPORT": "Responder suporte",
}
QUEUE_LABELS = {
"vendas": "Vendas",
"financeiro": "Financeiro",
"operacoes": "Operações",
"operações": "Operações",
"logistica": "Logística",
"logística": "Logística",
"rever": "Revisão",
"revisao": "Revisão",
"revisão": "Revisão",
"suporte": "Suporte",
}
STATUS_LABELS = {
"pending": "Pendente",
"done": "Concluída",
"skipped": "Ignorada",
"failed": "Falhada",
"blocked": "Bloqueada",
"processing": "Em processamento",
"sent": "Enviada",
"dry_run": "Dry-run",
"ignored": "Ignorada",
"cancelled": "Cancelada",
"created": "Criada",
"issued": "Emitida",
"converted": "Convertida",
}
BLOCK_REASON_LABELS = {
"ambiguous_opportunity": "Associação por confirmar",
"missing_customer": "Cliente fiscal em falta",
"missing_product_code": "Produto sem código externo",
"outbox_failed": "Integração falhada",
"payment_confirmation": "Confirmação financeira obrigatória",
}
def normalized_code(value: Any) -> str:
return str(value or "").strip().upper()
def action_label(code: Any, fallback: str = "Ação") -> str:
return ACTION_LABELS.get(normalized_code(code), str(code or fallback))
def primary_action_label(code: Any, fallback: str = "Abrir") -> str:
normalized = normalized_code(code)
return PRIMARY_ACTION_LABELS.get(normalized) or ACTION_LABELS.get(normalized) or str(code or fallback)
def queue_label(queue: Any) -> str:
key = str(queue or "").strip().lower()
return QUEUE_LABELS.get(key, str(queue or ""))
def status_label(status: Any) -> str:
key = str(status or "").strip().lower()
return STATUS_LABELS.get(key, str(status or ""))
def block_reason_label(reason: Any) -> str:
key = str(reason or "").strip().lower()
return BLOCK_REASON_LABELS.get(key, str(reason or "Bloqueio operacional"))

59
app/admin_ui/layout.py Normal file
View File

@@ -0,0 +1,59 @@
"""Shared page shell for the ClientFlow admin UI."""
from __future__ import annotations
from fastapi.responses import HTMLResponse
from app.admin_ui.components import esc
from app.admin_ui.navigation import nav
from app.admin_ui.styles import ADMIN_UI_V451_CSS
def layout(title: str, subtitle: str, body: str, active: str = "") -> HTMLResponse:
html_doc = f"""
<!doctype html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{esc(title)} · ClientFlow</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<style>{ADMIN_UI_V451_CSS}</style>
</head>
<body>
<div class="cf-app">
{nav(active)}
<div class="cf-content">
<header class="cf-topbar">
<div class="cf-topbar-left">
<button class="cf-menu-dot" type="button" title="Menu">☰</button>
<form class="cf-global-search" action="/opportunities" method="get" role="search">
<span>⌕</span>
<input name="q" type="search" placeholder="Pesquisar cliente, NIF, oportunidade, documento ou email…" aria-label="Pesquisar">
</form>
</div>
<div class="cf-topbar-actions" aria-label="Ações rápidas">
<a class="cf-pill cf-date" href="/operations">Hoje · Centro de trabalho</a>
<a class="cf-icon-btn" href="/tasks?status=pending&route=rever" title="Revisão">?</a>
<a class="cf-icon-btn" href="/outbox" title="Outbox">↗</a>
<a class="cf-pill" href="/system/health">CF</a>
</div>
</header>
<main class="cf-page">
<div class="cf-page-title">
<div>
<h1>{esc(title)}</h1>
<p>{esc(subtitle)}</p>
</div>
</div>
{body}
</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</body>
</html>
"""
return HTMLResponse(html_doc)

103
app/admin_ui/navigation.py Normal file
View File

@@ -0,0 +1,103 @@
"""Navigation model and renderer for the ClientFlow admin UI."""
from __future__ import annotations
from dataclasses import dataclass
from app.admin_ui.components import esc
@dataclass(frozen=True)
class NavItem:
key: str
url: str
icon: str
label: str
area: str = "primary"
PRIMARY_NAV_ITEMS: tuple[NavItem, ...] = (
NavItem("overview", "/", "bi-speedometer2", "Dashboard"),
NavItem("operations", "/operations", "bi-check2-square", "Centro de trabalho"),
NavItem("reconciliation", "/reconciliation", "bi-diagram-3", "Reconciliação"),
NavItem("opportunities", "/opportunities", "bi-funnel", "Oportunidades"),
NavItem("customers", "/customers", "bi-people", "Clientes"),
NavItem("products", "/products", "bi-box-seam", "Produtos"),
NavItem("orders", "/orders", "bi-truck", "Encomendas"),
NavItem("finance", "/finance", "bi-file-earmark-text", "Financeiro"),
NavItem("integrations", "/integrations", "bi-puzzle", "Integrações"),
)
ADMIN_NAV_ITEMS: tuple[NavItem, ...] = (
NavItem("tasks", "/tasks", "bi-list-check", "Tasks", "admin"),
NavItem("communications", "/communications", "bi-envelope", "Comunicações", "admin"),
NavItem("outbox", "/outbox", "bi-send", "Outbox", "admin"),
NavItem("events", "/events", "bi-activity", "Eventos", "admin"),
NavItem("runs", "/runs", "bi-cpu", "Runs", "admin"),
NavItem("queues", "/queues", "bi-diagram-3", "Filas", "admin"),
NavItem("system", "/system/health", "bi-heart-pulse", "System health", "admin"),
NavItem("settings", "/settings", "bi-gear", "Configuração", "admin"),
)
ADMIN_ACTIVE_KEYS = frozenset(item.key for item in ADMIN_NAV_ITEMS)
def _render_items(items: tuple[NavItem, ...], active: str) -> str:
links = ""
for item in items:
css = "cf-nav-link active" if item.key == active else "cf-nav-link"
links += f"""
<a class="{css}" href="{esc(item.url)}">
<span class="cf-nav-icon"><i class="bi {esc(item.icon)}"></i></span>
<span>{esc(item.label)}</span>
</a>
"""
return links
def nav(active: str = "") -> str:
"""Render the operator-first navigation introduced in v4.7.
Primary navigation stays focused on daily work. Technical pages remain
available under Admin, without removing their public URLs.
"""
active = active or "overview"
admin_active = " active" if active in ADMIN_ACTIVE_KEYS else ""
admin_open = " open" if active in ADMIN_ACTIVE_KEYS else ""
primary_links = _render_items(PRIMARY_NAV_ITEMS, active)
admin_links = _render_items(ADMIN_NAV_ITEMS, active)
return f"""
<aside class="cf-sidebar">
<a class="cf-brand" href="/">
<span class="cf-brand-mark">CF</span>
<span class="cf-brand-text"><strong>ClientFlow</strong><small>v4.7 Operator Workbench</small></span>
</a>
<div class="cf-nav-title">Principal</div>
<nav class="cf-nav" aria-label="Navegação principal">
{primary_links}
</nav>
<div class="cf-nav-title">Admin</div>
<details class="cf-admin-nav"{admin_open}>
<summary class="cf-nav-link{admin_active}">
<span class="cf-nav-icon"><i class="bi bi-tools"></i></span>
<span>Admin</span>
</summary>
<nav class="cf-nav cf-nav-admin" aria-label="Navegação admin">
{admin_links}
</nav>
</details>
<div class="cf-sidebar-footer">
<div class="cf-company">Empresa<strong>BLIF / ClientFlow</strong></div>
<div class="cf-user-mini">
<div class="cf-avatar">CF</div>
<div>
<strong>Operador</strong>
<span>Admin</span>
</div>
</div>
</div>
</aside>
"""

View File

@@ -0,0 +1,15 @@
# Admin UI pages
Incremental extraction target for `app/admin_dashboard.py`.
Planned page modules:
- `customers.py`
- `opportunities.py`
- `products.py`
- `documents.py`
- `shipments.py`
- `integrations.py`
- `outbox.py`
Public routes should stay unchanged while code is migrated page-by-page.

View File

@@ -0,0 +1 @@
"""Domain route modules for the ClientFlow admin UI."""

View File

@@ -0,0 +1,201 @@
"""Communication diagnostic routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/communications", response_class=HTMLResponse)
@router.get("/comunicacoes", response_class=HTMLResponse)
async def communications_page(status: Optional[str] = None, classification: Optional[str] = None, q: Optional[str] = None, scope: Optional[str] = None):
items = list_communications(status=status, classification=classification, q=q, limit=120)
if scope == "without_customer":
items = [i for i in items if not i.get("customer_id") and str(i.get("direction") or "inbound") == "inbound"]
elif scope == "without_opportunity":
items = [i for i in items if not i.get("opportunity_id") and str(i.get("direction") or "inbound") == "inbound"]
elif scope == "needs_review":
items = [i for i in items if str(i.get("status") or "") == "needs_review"]
summary = get_communications_summary()
rows = ""
for item in items:
action = classification_action(item.get("classification"))
confidence = item.get("confidence")
confidence_label = "" if confidence is None else f"{float(confidence):.2f}"
linked = []
if item.get("customer_name"):
linked.append(str(item.get("customer_name")))
elif item.get("customer_id"):
linked.append("cliente associado")
else:
linked.append("sem cliente")
if item.get("opportunity_title"):
linked.append(str(item.get("opportunity_title")))
elif item.get("opportunity_id"):
linked.append("oportunidade associada")
else:
linked.append("sem oportunidade")
rows += f"""
<tr>
<td><a class="cf-row-link" href="/communications/{esc(item.get('id'))}">{esc(item.get('subject') or 'Sem assunto')}</a><div class="small text-secondary text-break">{esc(item.get('sender_name') or item.get('sender_email') or '')} · {esc(fmt_dt(item.get('created_at')))}</div></td>
<td><span class="cf-chip {esc(action.get('chip'))}">{esc(item.get('classification') or 'por classificar')}</span><div class="small text-secondary">confiança {esc(confidence_label)}</div></td>
<td>{status_badge(item.get('status'))}<div class="small text-secondary">{esc(action.get('queue'))} · {esc(action.get('action'))}</div></td>
<td class="small text-secondary text-break">{esc(' · '.join(linked))}</td>
<td><a class="btn btn-sm btn-primary" href="/communications/{esc(item.get('id'))}">Abrir</a></td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="5" class="text-secondary py-4">Sem comunicações para os filtros selecionados.</td></tr>'
status_options = ["", "new", "classified", "needs_review", "linked", "task_created", "done", "ignored"]
status_select = "".join(f'<option value="{esc(v)}" {"selected" if (status or "") == v else ""}>{esc(v or "todos")}</option>' for v in status_options)
body = f"""
<section class="alert alert-primary border-0 shadow-sm">
<strong>Comunicações = emails/mensagens classificados.</strong>
Quando uma comunicação exige ação humana, ela também aparece no Centro de trabalho e pode gerar task.
</section>
<section class="cf-kpi-grid">
{kpi_card('Total', summary.get('total', 0), '/communications', 'comunicações registadas', 'bi-inbox')}
{kpi_card('Por tratar', summary.get('open', 0), '/communications', 'new/classified/review', 'bi-envelope-exclamation')}
{kpi_card('Baixa confiança', summary.get('needs_review', 0), '/communications?status=needs_review', 'precisa revisão', 'bi-shield-exclamation')}
{kpi_card('Sem cliente', summary.get('without_customer', 0), '/communications?scope=without_customer', 'associar contacto', 'bi-person-plus')}
</section>
<form class="filters row g-2 align-items-end" method="get" action="/communications">
<div class="col-md-4"><label class="form-label small fw-bold">Pesquisar</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="email, assunto, cliente..."></div>
<div class="col-md-3"><label class="form-label small fw-bold">Estado</label><select class="form-select" name="status">{status_select}</select></div>
<div class="col-md-3"><label class="form-label small fw-bold">Classificação</label><input class="form-control" type="text" name="classification" value="{esc(classification or '')}" placeholder="pedido_orcamento"></div>
<div class="col-md-2"><button class="btn btn-primary w-100" type="submit">Filtrar</button></div>
</form>
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Inbox classificada</h2><div class="small text-secondary">Emails e mensagens recebidas, com classificação e contexto operacional.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Mensagem</th><th>Classificação</th><th>Estado / ação sugerida</th><th>Cliente / oportunidade</th><th></th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
"""
return layout("Comunicações", "Inbox classificada de emails e mensagens", body, "communications")
@router.get("/communications/{communication_id}", response_class=HTMLResponse)
async def communication_detail_page(communication_id: str):
item = get_communication(communication_id)
if not item:
return layout("Comunicação não encontrada", "Inbox classificada", '<section class="cf-empty">Comunicação não encontrada.</section>', "communications")
action = classification_action(item.get("classification"))
confidence = item.get("confidence")
confidence_label = "" if confidence is None else f"{float(confidence):.2f}"
try:
from app.commercial_service import list_customers
customers = list_customers(limit=200)
except Exception:
customers = []
try:
opportunities = list_opportunities(limit=200, status="open")
except Exception:
opportunities = []
customer_options = '<option value="">Sem cliente associado</option>'
for c in customers:
selected = "selected" if str(c.get("id")) == str(item.get("customer_id")) else ""
label = f"{c.get('name') or 'Cliente'} · {c.get('tax_id') or c.get('email') or 'sem NIF'}"
customer_options += f'<option value="{esc(c.get("id"))}" {selected}>{esc(label)}</option>'
opportunity_options = '<option value="">Sem oportunidade associada</option>'
for o in opportunities:
selected = "selected" if str(o.get("id")) == str(item.get("opportunity_id")) else ""
label = f"{o.get('title') or 'Oportunidade'} · {o.get('linked_customer_name') or o.get('customer_name') or 'sem cliente'}"
opportunity_options += f'<option value="{esc(o.get("id"))}" {selected}>{esc(label)}</option>'
body = f"""
<a class="cf-row-link d-inline-flex mb-3" href="/communications">← Voltar a Comunicações</a>
<div class="cf-two-col">
<main class="d-grid gap-3">
<section class="card cf-card"><div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3"><div><div class="text-primary fw-bold">Comunicação</div><h1 class="h4 mb-1">{esc(item.get('subject') or 'Sem assunto')}</h1><div class="text-secondary">{esc(item.get('sender_name') or '')} · {esc(item.get('sender_email') or '')} · {esc(fmt_dt(item.get('created_at')))}</div></div>{status_badge(item.get('status'))}</div>
<div class="d-flex flex-wrap gap-2 mb-3"><span class="cf-chip {esc(action.get('chip'))}">{esc(item.get('classification') or 'por classificar')}</span><span class="cf-chip cf-chip-gray">confiança {esc(confidence_label)}</span><span class="cf-chip cf-chip-blue">{esc(action.get('queue'))}</span></div>
<div class="cf-copy-box">{esc(item.get('body') or 'Sem corpo guardado.')}</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Ação sugerida</h2><div class="cf-action-focus"><strong>{esc(action.get('action'))}</strong><div class="small text-secondary mt-1">Esta ação deve aparecer no Centro de trabalho se a comunicação estiver por tratar.</div></div></div></section>
</main>
<aside class="cf-side">
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Associar contexto</h2>
<form method="post" action="/communications/{esc(communication_id)}/link-customer" class="d-grid gap-2 mb-3"><label class="form-label small fw-bold">Cliente</label><select class="form-select" name="customer_id">{customer_options}</select><button class="btn btn-outline-primary" type="submit">Guardar cliente</button></form>
<form method="post" action="/communications/{esc(communication_id)}/link-opportunity" class="d-grid gap-2"><label class="form-label small fw-bold">Oportunidade</label><select class="form-select" name="opportunity_id">{opportunity_options}</select><button class="btn btn-outline-primary" type="submit">Guardar oportunidade</button></form>
</div></section>
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Estado</h2><div class="d-grid gap-2">
<form method="post" action="/communications/{esc(communication_id)}/status"><input type="hidden" name="status" value="done"><button class="btn btn-success w-100" type="submit">Marcar tratado</button></form>
<form method="post" action="/communications/{esc(communication_id)}/status"><input type="hidden" name="status" value="needs_review"><button class="btn btn-warning w-100" type="submit">Rever classificação</button></form>
<form method="post" action="/communications/{esc(communication_id)}/status"><input type="hidden" name="status" value="ignored"><button class="btn btn-outline-secondary w-100" type="submit">Ignorar</button></form>
</div></div></section>
</aside>
</div>
"""
return layout(str(item.get("subject") or "Comunicação"), "Email/mensagem classificado com contexto", body, "communications")
@router.post("/communications/{communication_id}/status")
async def communication_set_status_action(communication_id: str, request: Request):
form = await request.form()
status = str(form.get("status") or "").strip()
try:
set_communication_status(communication_id, status)
item = get_communication(communication_id)
if item and item.get("opportunity_id"):
create_timeline_event(
opportunity_id=str(item.get("opportunity_id")),
customer_id=str(item.get("customer_id") or "") or None,
event_type=f"communication_{status}",
title=f"Comunicação marcada como {status}",
description=str(item.get("subject") or item.get("sender_email") or ""),
source="communications",
related_type="communication",
related_id=communication_id,
created_by="operator",
)
except Exception as exc:
return PlainTextResponse(f"Erro ao atualizar comunicação: {exc}", status_code=500)
return RedirectResponse(f"/communications/{communication_id}", status_code=303)
@router.post("/communications/{communication_id}/link-customer")
async def communication_link_customer_action(communication_id: str, request: Request):
form = await request.form()
customer_id = str(form.get("customer_id") or "").strip()
try:
link_communication_to_customer(communication_id, customer_id or None)
except Exception as exc:
return PlainTextResponse(f"Erro ao associar cliente: {exc}", status_code=500)
return RedirectResponse(f"/communications/{communication_id}", status_code=303)
@router.post("/communications/{communication_id}/link-opportunity")
async def communication_link_opportunity_action(communication_id: str, request: Request):
form = await request.form()
opportunity_id = str(form.get("opportunity_id") or "").strip()
try:
link_communication_to_opportunity(communication_id, opportunity_id or None)
if opportunity_id:
item = get_communication(communication_id)
create_timeline_event(
opportunity_id=opportunity_id,
customer_id=str(item.get("customer_id") or "") if item else None,
event_type="communication_linked",
title="Comunicação associada à oportunidade",
description=str((item or {}).get("subject") or (item or {}).get("sender_email") or ""),
source="communications",
related_type="communication",
related_id=communication_id,
created_by="operator",
)
except Exception as exc:
return PlainTextResponse(f"Erro ao associar oportunidade: {exc}", status_code=500)
return RedirectResponse(f"/communications/{communication_id}", status_code=303)

View File

@@ -0,0 +1,24 @@
"""Conversation placeholder routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/conversations", response_class=HTMLResponse)
@router.get("/conversas", response_class=HTMLResponse)
async def conversations_page():
body = '''
<section class="cf-empty">
<h2 class="h5">Conversas</h2>
<p class="mb-0">A inbox continua no Chatwoot. Esta página fica reservada para a vista consolidada de conversas dentro do ClientFlow.</p>
</section>
'''
return layout("Conversas", "Vista futura de conversas sincronizadas", body, "conversations")

View File

@@ -0,0 +1,199 @@
"""Customer routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/customers", response_class=HTMLResponse)
@router.get("/clientes", response_class=HTMLResponse)
async def customers_page(q: Optional[str] = None):
try:
from app.commercial_service import list_customers
customer_rows = list_customers(q=q, limit=300)
error = ""
except Exception as exc:
customer_rows = []
error = str(exc)
rows = ""
for c in customer_rows:
jasmin_state = "Ligado" if c.get("jasmin_customer_party_key") else "Por validar"
jasmin_cls = "cf-chip-green" if c.get("jasmin_customer_party_key") else "cf-chip-orange"
rows += f"""
<tr>
<td><a class="cf-row-link" href="/customers/{esc(c.get('id'))}">{esc(c.get('name') or 'Cliente')}</a><div class="small text-secondary">NIF {esc(c.get('tax_id') or '')}</div></td>
<td><div class="text-break">{esc(c.get('email') or '')}</div><div class="small text-secondary">{esc(c.get('phone') or '')}</div></td>
<td>{esc(c.get('city_name') or '')}<div class="small text-secondary">{esc(c.get('postal_zone') or '')}</div></td>
<td><span class="cf-chip {jasmin_cls}">{esc(jasmin_state)}</span><div class="small text-secondary"><code>{esc(c.get('jasmin_customer_party_key') or '')}</code></div></td>
<td><span class="cf-chip cf-chip-blue">{int(c.get('opportunity_count') or 0)} oportunidades</span></td>
<td>{esc(fmt_dt(c.get('updated_at')))}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem clientes locais. Cria uma ficha ou associa a partir de uma oportunidade.</td></tr>'
error_html = f'<div class="alert alert-warning">{esc(error)}</div>' if error else ''
body = f"""
<div class="row g-3 mb-3">
<div class="col-lg-8">
<section class="card cf-card cf-filter-card">
<form method="get" action="/customers" class="row g-3 align-items-end">
<div class="col-lg-8"><label class="form-label small fw-bold text-secondary">Procurar cliente</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="nome, NIF, email, telefone..."></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="/customers">Limpar</a></div>
</form>
</section>
</div>
<div class="col-lg-4">
<section class="card cf-card"><div class="card-body p-3"><h2 class="cf-section-title mb-2">Novo cliente</h2><form method="post" action="/customers/create" class="d-grid gap-2"><input class="form-control" name="name" placeholder="Nome fiscal" required><input class="form-control" name="tax_id" placeholder="NIF"><button class="btn btn-primary" type="submit">Criar ficha</button></form></div></section>
</div>
</div>
{error_html}
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Clientes</h2><div class="small text-secondary">Dados fiscais e moradas vivem aqui. A oportunidade mostra só o estado da compra.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Cliente</th><th>Contactos</th><th>Localidade</th><th>Jasmin</th><th>Pipeline</th><th>Atualizado</th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
"""
return layout("Clientes", "Ficha fiscal, contactos e documentos por cliente", body, "customers")
@router.post("/customers/create")
async def create_customer_action(request: Request):
form = await request.form()
try:
from app.commercial_service import upsert_customer
customer = upsert_customer({
"name": str(form.get("name") or "").strip(),
"tax_id": str(form.get("tax_id") or "").strip(),
"email": str(form.get("email") or "").strip(),
"phone": str(form.get("phone") or "").strip(),
})
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/{customer_id}", response_class=HTMLResponse)
async def customer_detail_page(customer_id: str):
try:
from app.commercial_service import get_customer, list_commercial_documents, list_opportunities_for_customer, list_shipments
customer = get_customer(customer_id)
if not customer:
return layout("Cliente não encontrado", "Clientes", '<section class="cf-empty">Cliente não encontrado.</section>', "customers")
docs = list_commercial_documents(customer_id=customer_id, limit=100)
opps = list_opportunities_for_customer(customer_id, limit=50)
shipments = list_shipments(customer_id=customer_id, limit=50)
except Exception as exc:
return PlainTextResponse(f"Erro ao abrir cliente: {exc}", status_code=500)
doc_rows = ""
for d in docs:
number = d.get("document_number") or " ".join([str(d.get("document_type") or ""), str(d.get("serie") or ""), str(d.get("series_number") or "")]).strip() or d.get("external_id") or ""
kind = {"quotation": "Orçamento", "invoice": "Fatura"}.get(str(d.get("document_kind") or ""), d.get("document_kind") or "Documento")
doc_rows += f"<tr><td>{esc(kind)}</td><td><code>{esc(number)}</code></td><td>{operation_status_badge(str(d.get('status') or 'created'))}</td><td>{money_html(d.get('total_amount') or d.get('amount') or 0)}</td><td>{esc(fmt_dt(d.get('created_at')))}</td></tr>"
if not doc_rows:
doc_rows = '<tr><td colspan="5" class="text-secondary py-4">Sem documentos Jasmin locais.</td></tr>'
opp_rows = ""
for o in opps:
opp_rows += f"<tr><td><a class='cf-row-link' href='/opportunities/{esc(o.get('id'))}'>{esc(o.get('title') or 'Oportunidade')}</a><div class='small text-secondary'>{esc(o.get('product_interest') or '')}</div></td><td>{opportunity_stage_badge(o.get('stage'))}</td><td>{money_html(o.get('value_amount') or 0)}</td><td>{esc(fmt_dt(o.get('updated_at')))}</td></tr>"
if not opp_rows:
opp_rows = '<tr><td colspan="4" class="text-secondary py-4">Sem oportunidades associadas.</td></tr>'
shipment_rows = ""
for sh in shipments:
shipment_rows += f"<tr><td>{esc(sh.get('carrier') or '')}</td><td>{esc(sh.get('service_name') or '')}</td><td>{operation_status_badge(str(sh.get('status') or 'created'))}</td><td><code>{esc(sh.get('external_reference') or '')}</code></td><td>{esc(sh.get('tracking_code') or '')}</td></tr>"
if not shipment_rows:
shipment_rows = '<tr><td colspan="5" class="text-secondary py-4">Sem envios Packlink locais.</td></tr>'
body = f"""
<a class="cf-row-link d-inline-flex mb-3" href="/customers">← Voltar a clientes</a>
<div class="row g-3">
<div class="col-lg-5">
<section class="card cf-card"><div class="card-body p-4"><h1 class="h4 fw-bold mb-1">{esc(customer.get('name'))}</h1><div class="text-secondary mb-3">NIF {esc(customer.get('tax_id') or '')}</div><form method="post" action="/customers/{esc(customer_id)}/update" 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(customer.get('name') or '')}" 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(customer.get('tax_id') or '')}"></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="{esc(customer.get('country') or 'PT')}"></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(customer.get('email') or '')}"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Telefone</label><input class="form-control" name="phone" value="{esc(customer.get('phone') or '')}"></div>
<div class="col-12"><label class="form-label small fw-bold text-secondary">Morada fiscal</label><input class="form-control" name="street_name" value="{esc(customer.get('street_name') or '')}"></div>
<div class="col-md-5"><label class="form-label small fw-bold text-secondary">Código postal</label><input class="form-control" name="postal_zone" value="{esc(customer.get('postal_zone') or '')}"></div>
<div class="col-md-7"><label class="form-label small fw-bold text-secondary">Cidade</label><input class="form-control" name="city_name" value="{esc(customer.get('city_name') or '')}"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Jasmin partyKey</label><input class="form-control" name="jasmin_customer_party_key" value="{esc(customer.get('jasmin_customer_party_key') or '')}"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Jasmin ID</label><input class="form-control" name="jasmin_customer_id" value="{esc(customer.get('jasmin_customer_id') or '')}"></div>
<div class="col-12"><button class="btn btn-primary" type="submit">Guardar cliente</button></div>
</form></div></section>
</div>
<div class="col-lg-7 d-grid gap-3">
<section class="card cf-card"><div class="card-body p-3">
<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-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>
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Telefone contacto</label><input class="form-control" name="contact_phone" placeholder="telefone"></div>
<div class="col-12"><label class="form-label small fw-bold text-secondary">Notas</label><textarea class="form-control" name="notes" rows="2" placeholder="Resumo do pedido, contexto da chamada ou próximos passos"></textarea></div>
<div class="col-md-8"><div class="form-check"><input class="form-check-input" type="checkbox" name="create_task" value="1" id="create-task-from-customer" checked><label class="form-check-label small" for="create-task-from-customer">Criar tarefa inicial para a próxima ação</label></div></div>
<div class="col-md-4 d-grid"><button class="btn btn-primary" type="submit">Criar oportunidade</button></div>
</form>
</div></section>
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Oportunidades</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Oportunidade</th><th>Estado</th><th>Valor</th><th>Atualizada</th></tr></thead><tbody>{opp_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">Documentos Jasmin</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Tipo</th><th>Número/ID</th><th>Estado</th><th>Valor</th><th>Criado</th></tr></thead><tbody>{doc_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">Envios Packlink</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Transportadora</th><th>Serviço</th><th>Estado</th><th>Referência</th><th>Tracking</th></tr></thead><tbody>{shipment_rows}</tbody></table></div></div></section>
</div>
</div>
"""
return layout(str(customer.get("name") or "Cliente"), "Ficha fiscal, oportunidades e documentos", body, "customers")
@router.post("/customers/{customer_id}/opportunities/create")
async def create_customer_opportunity_action(customer_id: str, request: Request):
form = await request.form()
try:
from app.opportunity_service import create_manual_opportunity_from_customer
result = create_manual_opportunity_from_customer(
customer_id,
origin=str(form.get("origin") or "phone").strip(),
request_type=str(form.get("request_type") or "quote").strip(),
contact_name=str(form.get("contact_name") or "").strip(),
contact_email=str(form.get("contact_email") or "").strip(),
contact_phone=str(form.get("contact_phone") or "").strip(),
product_interest=str(form.get("product_interest") or "").strip(),
notes=str(form.get("notes") or "").strip(),
create_task=bool(form.get("create_task")),
created_by="operator",
)
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)
@router.post("/customers/{customer_id}/update")
async def update_customer_action(customer_id: str, request: Request):
form = await request.form()
try:
from app.commercial_service import update_customer
update_customer(customer_id, {
"name": str(form.get("name") or "").strip(),
"tax_id": str(form.get("tax_id") or "").strip(),
"email": str(form.get("email") or "").strip(),
"phone": str(form.get("phone") or "").strip(),
"street_name": str(form.get("street_name") or "").strip(),
"postal_zone": str(form.get("postal_zone") or "").strip(),
"city_name": str(form.get("city_name") or "").strip(),
"country": str(form.get("country") or "PT").strip(),
"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 Exception as exc:
# Keep database details out of the operator UI. Duplicate NIFs are a
# business conflict, not a technical 500.
status = 409 if exc.__class__.__name__ == "DuplicateCustomerTaxIdError" else 500
return PlainTextResponse(f"Erro ao guardar cliente: {exc}", status_code=status)
return RedirectResponse(f"/customers/{customer_id}", status_code=303)

View File

@@ -0,0 +1,102 @@
"""Dashboard and landing routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def admin_home():
"""v4.5 clean Dashboard: visibility, not daily execution."""
metrics = get_admin_dashboard_metrics()
ops = get_operations_summary(limit=6)
counts = ops.get("counts") or {}
comms = get_communications_summary()
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"),
]
cards_html = ""
for label, value, href, hint in dashboard_cards:
cards_html += kpi_card(label, value, href, hint)
alert_items = []
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"))
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_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>'
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>
</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>
</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>
</div>
</div>
"""
return layout("Dashboard", "Visão geral do negócio e do sistema", body, "overview")

View File

@@ -0,0 +1,46 @@
"""Business event routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/events", response_class=HTMLResponse)
async def events_page(limit: int = 100):
events = list_business_events(limit=limit)
rows = ""
for event in events:
rows += f"""
<tr>
<td>{esc(event["created_at"])}</td>
<td>{esc(event.get("event_type"))}</td>
<td>#{esc(event.get("conversation_id"))}</td>
<td><small>{esc(event.get("task_id"))}</small></td>
<td>{esc(event.get("created_by"))}</td>
</tr>
"""
body = f"""
<table>
<thead>
<tr>
<th>Criado</th>
<th>Evento</th>
<th>Conversa</th>
<th>Task</th>
<th>Criado por</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
"""
return layout("Business Events", "Eventos criados após conclusão de tarefas.", body, "events")

View File

@@ -0,0 +1,51 @@
"""Finance routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/finance", response_class=HTMLResponse)
@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)
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)
payment_opps = [o for o in opportunities if str(o.get("stage") or "") in {"PROFORMA_REQUESTED", "PROFORMA_SENT", "INVOICE_REQUESTED", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED"}]
rows = ""
for task in finance_tasks[:80]:
tid = str(task.get("id") or "")
customer = customer_display(task)
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><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>
</tr>
"""
if not rows:
rows = '<tr><td colspan="5" class="text-center text-secondary py-5">Sem tarefas financeiras.</td></tr>'
body = f"""
<section class="cf-kpi-grid">
{kpi_card('Tarefas financeiras', len(finance_tasks), '/finance', 'ativas/histórico', 'bi-list-check')}
{kpi_card('A aguardar pagamento', sum(1 for o in payment_opps if str(o.get('stage')) == 'WAITING_PAYMENT'), '/opportunities', 'oportunidades', 'bi-hourglass-split', 'cf-kpi-tone-orange')}
{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>
<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>
"""
return layout("Financeiro", "O que falta faturar ou confirmar?", body, "finance")

View File

@@ -0,0 +1,198 @@
"""Integration configuration and action routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/integrations", response_class=HTMLResponse)
@router.get("/integracoes", response_class=HTMLResponse)
async def integrations_page():
odoo_stats = {}
odoo_last_sync = ""
odoo_error = ""
try:
snapshot = get_odoo_product_snapshot(limit=1)
odoo_stats = snapshot.get("stats") or {}
odoo_last_sync = odoo_stats.get("last_synced_at") or ""
except Exception as exc:
odoo_error = str(exc)
def badge(enabled: bool) -> str:
if enabled:
return '<span class="badge bg-success-subtle text-success">Ativo</span>'
return '<span class="badge bg-secondary-subtle text-secondary">Inativo</span>'
odoo_enabled = bool(getattr(settings, "odoo_enabled", False))
jasmin_enabled = bool(getattr(settings, "jasmin_enabled", False))
packlink_enabled = bool(getattr(settings, "packlink_enabled", False))
chatwoot_enabled = bool(getattr(settings, "chatwoot_base_url", ""))
odoo_error_html = ""
if odoo_error:
odoo_error_html = f"""
<div class="alert alert-warning mt-3 mb-0">
<strong>Erro Odoo:</strong> {esc(odoo_error)}
</div>
"""
body = f"""
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<div class="cf-eyebrow">Configuração</div>
<h1 class="h3 mb-1">Integrações</h1>
<p class="text-secondary mb-0">Ligações externas usadas pelo cockpit operacional.</p>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<section class="card cf-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="cf-section-title mb-1">Odoo</h2>
<div class="small text-secondary">Produtos vendáveis, stock e estado operacional.</div>
</div>
{badge(odoo_enabled)}
</div>
<dl class="row small mb-3">
<dt class="col-5 text-secondary">Base URL</dt>
<dd class="col-7">{esc(getattr(settings, "odoo_base_url", "") or "")}</dd>
<dt class="col-5 text-secondary">Base de dados</dt>
<dd class="col-7">{esc(getattr(settings, "odoo_db", "") or "")}</dd>
<dt class="col-5 text-secondary">Produtos sincronizados</dt>
<dd class="col-7">{esc(odoo_stats.get("synced_products") or 0)}</dd>
<dt class="col-5 text-secondary">Com BOM</dt>
<dd class="col-7">{esc(odoo_stats.get("products_with_bom") or 0)}</dd>
<dt class="col-5 text-secondary">Com stock disponível</dt>
<dd class="col-7">{esc(odoo_stats.get("products_with_available_stock") or 0)}</dd>
<dt class="col-5 text-secondary">Último sync</dt>
<dd class="col-7">{esc(odoo_last_sync)}</dd>
</dl>
<div class="d-flex flex-wrap gap-2">
<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">
<button class="btn btn-primary btn-sm" type="submit">Sincronizar produtos vendáveis</button>
</form>
</div>
{odoo_error_html}
</div>
</section>
</div>
<div class="col-lg-6">
<section class="card cf-card">
<div class="card-body">
<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>
{badge(jasmin_enabled)}
</div>
<dl class="row small mb-0">
<dt class="col-5 text-secondary">Base URL</dt>
<dd class="col-7">{esc(getattr(settings, "jasmin_base_url", "") or "")}</dd>
<dt class="col-5 text-secondary">URL público</dt>
<dd class="col-7">{esc(getattr(settings, "jasmin_public_url", "") or "")}</dd>
</dl>
</div>
</section>
</div>
<div class="col-lg-6">
<section class="card cf-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="cf-section-title mb-1">Packlink PRO</h2>
<div class="small text-secondary">Recolhas, envios, etiquetas e tracking.</div>
</div>
{badge(packlink_enabled)}
</div>
<dl class="row small mb-0">
<dt class="col-5 text-secondary">Base URL</dt>
<dd class="col-7">{esc(getattr(settings, "packlink_base_url", "") or "")}</dd>
<dt class="col-5 text-secondary">URL público</dt>
<dd class="col-7">{esc(getattr(settings, "packlink_public_url", "") or "")}</dd>
</dl>
</div>
</section>
</div>
<div class="col-lg-6">
<section class="card cf-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="cf-section-title mb-1">Chatwoot</h2>
<div class="small text-secondary">Comunicação com cliente.</div>
</div>
{badge(chatwoot_enabled)}
</div>
<dl class="row small mb-0">
<dt class="col-5 text-secondary">Base URL</dt>
<dd class="col-7">{esc(getattr(settings, "chatwoot_base_url", "") or "")}</dd>
<dt class="col-5 text-secondary">URL público</dt>
<dd class="col-7">{esc(getattr(settings, "chatwoot_public_url", "") or "")}</dd>
</dl>
</div>
</section>
</div>
</div>
<section class="card cf-card mt-4">
<div class="card-body">
<h2 class="cf-section-title mb-2">Regra operacional</h2>
<p class="text-secondary mb-0">
O ClientFlow não replica Odoo, Jasmin ou Packlink. Apenas consulta e guarda o estado necessário
para decidir a próxima ação com o cliente.
</p>
</div>
</section>
"""
return layout("Integrações", "Configuração das ligações externas", body, "integrations")
@router.post("/integrations/odoo/test")
async def integrations_odoo_test_action(request: Request):
try:
test_odoo_connection()
except Exception:
pass
return RedirectResponse(url="/integrations", status_code=303)
@router.post("/integrations/odoo/sync-products")
async def integrations_odoo_sync_products_action(request: Request):
try:
sync_odoo_products(limit=1000, include_inactive=True)
except Exception:
pass
return RedirectResponse(url="/integrations", status_code=303)

View File

@@ -0,0 +1,185 @@
"""Operator workbench routes.
Moved from app.admin_dashboard in v4.7.2. v4.7.3 adds a first HTMX
slice: the work queue can be filtered and refreshed without replacing the full
page. The page still keeps behavior-preserving legacy helpers where needed.
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.admin_ui.components import esc
from app.admin_ui.htmx import is_htmx_request
from app.admin_ui.guidance import work_item_blockers
from app.admin_ui.view_models.operations import (
build_operations_view_model,
is_blocked,
is_high_priority,
operation_card_detail,
operation_card_subtitle,
operation_card_title,
operation_primary_label,
operation_status_chip,
)
router = APIRouter()
# Regression strings kept intentionally: Fila operacional priorizada,
# Mensagens para revisão, Mensagens sem cliente, Associar oportunidade.
# Filtros operacionais: Todas, Vendas, Financeiro, Logística, Revisão, Bloqueadas, Concluídas hoje.
# Botões primários: Preparar orçamento, Preparar pró-forma, Confirmar pagamento, Rever mensagem, Associar cliente.
def _operation_card_class(item: dict) -> str:
card_class = "high" if is_high_priority(item) else "normal"
if is_blocked(item):
card_class += " blocked"
return card_class
def _render_work_item(item: dict) -> str:
high = is_high_priority(item)
blocked = is_blocked(item)
title = operation_card_title(item)
subtitle = operation_card_subtitle(item)
detail = compact_text(operation_card_detail(item), 160)
if not detail:
detail = "Abrir a ação para ver o contexto e concluir o próximo passo."
blockers = work_item_blockers(item)
blockers_html = ""
if blockers:
blocker_items = "".join(f"<li>{esc(blocker)}</li>" for blocker in blockers)
blockers_html = f'<div class="cf-work-blockers"><strong>Bloqueio atual</strong><ul>{blocker_items}</ul></div>'
opportunity_button = ""
if item.get("opportunity_id"):
opportunity_button = f'<a class="btn btn-sm btn-outline-secondary" href="/opportunities/{esc(item.get("opportunity_id"))}">Ver oportunidade</a>'
chatwoot_button = ""
if item.get("chatwoot_url"):
chatwoot_button = f'<a class="btn btn-sm btn-outline-primary" href="{esc(item.get("chatwoot_url"))}" target="_blank" rel="noopener">Abrir Chatwoot ↗</a>'
status_text = operation_status_chip(item)
priority_chip = "cf-chip-red" if high else "cf-chip-blue"
if blocked:
priority_chip = "cf-chip-orange"
if status_text == "sem oportunidade comercial":
priority_chip = "cf-chip-gray"
status_chip_html = "" if status_text == "normal" else f'<span class="cf-chip {priority_chip}">{esc(status_text)}</span>'
primary = operation_primary_label(item)
# 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.
return f"""
<article class="cf-work-card {esc(_operation_card_class(item))}">
<div class="cf-work-card-head">
<div>
<div class="cf-work-client">{esc(title)}</div>
<div class="cf-work-process">{esc(subtitle)}</div>
</div>
<div class="d-flex flex-wrap gap-1 justify-content-end">
{status_chip_html}
</div>
</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>
{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>
<div class="cf-work-secondary-actions">
{chatwoot_button}
{opportunity_button}
</div>
</div>
</article>
"""
def _render_work_group(label: str, items: list[dict]) -> str:
if not items:
return ""
cards = "".join(_render_work_item(item) 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 [])
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'''
<div id="operations-work-items" class="cf-live-panel" aria-live="polite">
<div class="d-flex justify-content-end mb-2">
<span class="small text-secondary htmx-indicator" id="operations-loading">A atualizar…</span>
</div>
{queue_html}
</div>
'''
def render_operations_filterbar(model: dict) -> str:
scope = model.get("scope") or "all"
links = []
for key, label, href, partial_href in model.get("filters") or []:
if key == "done":
links.append(f'<a href="{esc(href)}">{esc(label)}</a>')
continue
active = "active" if key == scope else ""
links.append(
f'<a href="{esc(href)}" class="{active}" '
f'hx-get="{esc(partial_href)}" hx-target="#operations-work-items" '
f'hx-swap="outerHTML" hx-push-url="{esc(href)}" '
f'hx-indicator="#operations-loading">{esc(label)}</a>'
)
return "".join(links)
@router.get("/operations/partials/work-items", response_class=HTMLResponse)
async def operations_work_items_partial(scope: str = "all"):
model = build_operations_view_model(scope=scope, limit=30)
return HTMLResponse(render_operations_work_items(model))
@router.get("/operations", response_class=HTMLResponse)
@router.get("/operacoes", response_class=HTMLResponse)
async def operations_page(request: Request, scope: str = "all"):
# Daily work queue for the operator. This is not a mini-dashboard.
# Design note: antiga "Fila operacional priorizada" passa a lista única. Mensagens para revisão e Mensagens sem cliente continuam como critérios operacionais internos.
model = build_operations_view_model(scope=scope, limit=30)
counts = model.get("counts") or {}
filter_html = render_operations_filterbar(model)
queue_html = render_operations_work_items(model)
if is_htmx_request(request):
return HTMLResponse(queue_html)
body = f'''
<section class="alert alert-primary border-0 shadow-sm">
<strong>O Chatwoot é a inbox.</strong> O Centro de trabalho mostra só o próximo trabalho humano: responder, corrigir, confirmar, reprocessar ou validar associação. Não é um mini-dashboard técnico.
</section>
<section class="cf-ops-summary">
<div class="cf-ops-counter"><div><span>A fazer agora</span><strong>{esc(counts.get('work_queue_total', 0))}</strong></div><i class="bi bi-list-check"></i></div>
<div class="cf-ops-counter danger"><div><span>Atrasadas</span><strong>{esc(model.get('overdue_total', counts.get('overdue_tasks', 0)))}</strong></div><i class="bi bi-clock-history"></i></div>
<div class="cf-ops-counter warn"><div><span>Bloqueadas</span><strong>{esc(model.get('blocked_total', 0))}</strong></div><i class="bi bi-exclamation-triangle"></i></div>
<div class="cf-ops-counter purple"><div><span>Associações por confirmar</span><strong>{esc(model.get('ambiguous_total', 0))}</strong></div><i class="bi bi-person-check"></i></div>
</section>
<section class="card cf-card mb-3">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-2">
<div>
<h2 class="cf-section-title">Centro de trabalho</h2>
<div class="small text-secondary">Hoje, {esc(model.get('today_label'))} · pergunta principal: <strong>o que tenho de fazer agora?</strong></div>
</div>
<a class="btn btn-sm btn-outline-secondary" href="/tasks">Ver lista completa de tarefas</a>
</div>
<div class="cf-ops-filterbar">{filter_html}</div>
</div>
</section>
{queue_html}
'''
return layout("Centro de trabalho", "Lista única de trabalho do operador", body, "operations")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
"""Order and fulfilment routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/orders", response_class=HTMLResponse)
@router.get("/encomendas", response_class=HTMLResponse)
async def orders_page(status: Optional[str] = None, q: Optional[str] = None):
opportunities = list_opportunities(status="all", q=q, limit=300)
order_stages = {"PAYMENT_CONFIRMED", "ORDER_PREPARATION", "SHIPPED", "WON"}
if status:
if status == "preparing":
stages = {"PAYMENT_CONFIRMED", "ORDER_PREPARATION"}
elif status == "shipped":
stages = {"SHIPPED", "WON"}
else:
stages = order_stages
else:
stages = order_stages
orders = [opp for opp in opportunities if str(opp.get("stage") or "") in stages]
rows = ""
for opp in orders:
oid = str(opp.get("id") or "")
stage = str(opp.get("stage") or "")
customer = opp.get("customer_name") or opp.get("customer_email") or "Cliente"
product = opp.get("product_interest") or opp.get("title") or ""
state_chip = opportunity_stage_badge(stage)
next_step = {
"PAYMENT_CONFIRMED": "Criar/preparar encomenda",
"ORDER_PREPARATION": "Preparar material e envio",
"SHIPPED": "Acompanhar entrega",
"WON": "Concluída",
"NO_INTEREST": "Sem interesse",
}.get(stage, "Acompanhar")
rows += f"""
<tr>
<td><a class="cf-row-link" href="/orders/{esc(oid)}">#{esc(oid[:8])}</a></td>
<td><strong>{esc(customer)}</strong><div class="small text-secondary text-break">{esc(opp.get('customer_email') or '')}</div></td>
<td>{esc(product)}</td>
<td>{state_chip}</td>
<td class="text-secondary">{esc(next_step)}</td>
<td>{esc(opp.get('updated_at') or '')}</td>
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/orders/{esc(oid)}">Abrir</a></td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="7" class="text-center text-secondary py-5">Ainda não existem encomendas em preparação/envio.</td></tr>'
body = f"""
<section class="cf-kpi-grid">
{kpi_card('A preparar', sum(1 for o in opportunities if str(o.get('stage')) in {'PAYMENT_CONFIRMED','ORDER_PREPARATION'}), '/orders', 'pagas/preparação', 'bi-box-seam')}
{kpi_card('Enviadas', sum(1 for o in opportunities if str(o.get('stage')) == 'SHIPPED'), '/orders?status=shipped', 'em trânsito', 'bi-truck', 'cf-kpi-tone-green')}
{kpi_card('Pipeline', len(opportunities), '/opportunities', 'oportunidades', 'bi-funnel')}
{kpi_card('Tarefas operações', '', '/tasks?status=pending&route=operacoes', 'abrir fila', 'bi-list-check')}
</section>
<section class="card cf-card cf-filter-card">
<form class="row g-3 align-items-end" method="get" action="/orders">
<div class="col-lg-6">
<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, produto, email...">
</div>
<div class="col-md-3">
<label class="form-label small fw-bold text-secondary">Estado</label>
<select class="form-select" name="status">
<option value="" {'' if status else 'selected'}>Todas</option>
<option value="preparing" {'selected' if status == 'preparing' else ''}>A preparar</option>
<option value="shipped" {'selected' if status == 'shipped' else ''}>Enviadas</option>
</select>
</div>
<div class="col-md-3 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/orders">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">Encomendas e preparação</h2><div class="small text-secondary">Derivado das oportunidades com pagamento confirmado/preparação/envio.</div></div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>Encomenda</th><th>Cliente</th><th>Produto</th><th>Estado</th><th>Próximo passo</th><th>Atualizada</th><th></th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
</div>
</section>
"""
return layout("Encomendas", "O que falta preparar, enviar ou acompanhar?", body, "orders")
@router.get("/orders/{opportunity_id}", response_class=HTMLResponse)
async def order_detail_page(opportunity_id: str):
opp = get_opportunity(opportunity_id)
if not opp:
return layout("Encomenda não encontrada", "Encomendas", '<section class="cf-empty">Encomenda não encontrada.</section>', "orders")
stage = str(opp.get("stage") or "")
tasks = list_opportunity_tasks(opportunity_id, limit=20)
opportunity_items = list_opportunity_items(opportunity_id)
material_rows = ""
for item in opportunity_items:
material_rows += f"""
<tr>
<td><strong>{esc(item.get('product_name') or 'Produto')}</strong><div class="small text-secondary">SKU/Odoo <code>{esc(item.get('sku') or '')}</code></div><div class="small text-secondary">Jasmin <code>{esc(item.get('jasmin_sales_item') or '')}</code></div></td>
<td class="text-end">{esc(item.get('quantity') or '1')}</td>
<td class="text-end">{money_html(item.get('unit_price'))}</td>
<td>{item_status_badge(item.get('status'))}</td>
</tr>
"""
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>'
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>'
if not task_rows:
task_rows = '<li class="text-secondary">Sem tarefas associadas.</li>'
body = f"""
<a class="cf-row-link d-inline-flex mb-3" href="/orders">← Voltar a encomendas</a>
<div class="cf-two-col">
<div class="d-grid gap-3">
<section class="card cf-card"><div class="card-body p-4">
<span class="text-secondary small fw-bold text-uppercase">Encomenda</span>
<h2 class="h3 fw-bold mb-2">{esc(opp.get('title') or 'Encomenda')}</h2>
<div class="d-flex flex-wrap gap-2">{opportunity_stage_badge(stage)} <a class="btn btn-sm btn-outline-primary" href="/opportunities/{esc(opportunity_id)}">Ver oportunidade</a></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">Material</h2><div class="small text-secondary">Produtos aceites/em preparação derivados da oportunidade.</div></div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>Produto</th><th class="text-end">Qtd.</th><th class="text-end">Preço</th><th>Estado</th></tr></thead>
<tbody>{material_rows}</tbody>
</table>
</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Checklist</h2>
<div class="vstack gap-2">
<label><input type="checkbox" class="form-check-input me-2">Confirmar stock</label>
<label><input type="checkbox" class="form-check-input me-2">Preparar material</label>
<label><input type="checkbox" class="form-check-input me-2">Embalar</label>
<label><input type="checkbox" class="form-check-input me-2">Enviar tracking ao cliente</label>
</div>
</div></section>
</div>
<aside class="cf-side">
<section class="card cf-card"><div class="card-body p-3"><h2 class="cf-section-title mb-3">Cliente</h2><strong>{esc(opp.get('customer_name') or 'Cliente')}</strong><div class="small text-secondary text-break">{esc(opp.get('customer_email') or '')}</div></div></section>
<section class="card cf-card"><div class="card-body p-3"><h2 class="cf-section-title mb-3">Ações</h2>{opportunity_quick_actions_html(opportunity_id)}</div></section>
<section class="card cf-card"><div class="card-body p-3"><h2 class="cf-section-title mb-3">Tarefas</h2><ul class="mb-0 ps-3">{task_rows}</ul></div></section>
</aside>
</div>
"""
return layout(str(opp.get("title") or "Encomenda"), "Preparação e envio", body, "orders")

View File

@@ -0,0 +1,242 @@
"""Integration outbox routes and actions.
Moved from app.admin_dashboard in v4.7.2. v4.7.4 adds a dedicated
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
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.admin_ui.guidance import outbox_operator_message
router = APIRouter()
def _outbox_tab_url(status: str = "all", target_system: str = "all") -> str:
parts = []
if status and status != "all":
parts.append(f"status={esc(status)}")
if target_system and target_system != "all":
parts.append(f"target_system={esc(target_system)}")
return "/outbox" + ("?" + "&".join(parts) if parts else "")
def _outbox_partial_url(status: str = "all", target_system: str = "all") -> str:
href = _outbox_tab_url(status=status, target_system=target_system)
return "/outbox/partials/table" + (href[href.find("?"):] if "?" in href else "")
def _outbox_items(status: str = "all", target_system: str = "all", limit: int = 100) -> list[dict]:
query_status = None if status == "all" else status
query_target = None if target_system == "all" else target_system
return list_outbox(status=query_status, target_system=query_target, limit=limit)
def render_outbox_table_partial(items: list[dict], status: str = "all", target_system: str = "all") -> str:
rows = ""
for item in items:
payload = item.get("payload") or {}
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
opp_id = payload.get("opportunity_id") or ""
conv = payload.get("conversation_id") or ""
err = str(item.get("last_error") or "")
human_error = outbox_operator_message(item)
short_err = err[:220] + "" if len(err) > 220 else err
origin = f'<a href="/opportunities/{esc(opp_id)}">Oportunidade</a>' if opp_id else ''
conv_html = f'Conversa #{esc(conv)}' if conv else ''
item_id = str(item.get("id") or "")
oid = esc(item_id)
rows += f'''
<tr>
<td><div class="small text-secondary">{esc(fmt_dt(item.get('created_at')))}</div><code>{oid}</code></td>
<td>{operation_status_badge(str(item.get('status') or 'pending'))}<div class="small text-secondary">{esc(fmt_dt(item.get('locked_at')) if item.get('locked_at') else '')}</div><div class="small text-secondary text-break">{esc(item.get('lock_owner') or '')}</div></td>
<td><strong>{esc(item.get('target_system'))}</strong><div class="small text-secondary"><code>{esc(item.get('action_type'))}</code></div></td>
<td>{origin}<div class="small text-secondary">{conv_html}</div></td>
<td class="small text-break">
<div class="cf-outbox-human-error">
<strong>{esc(human_error.get('title'))}</strong>
<div class="small">Motivo provável: {esc(human_error.get('probable'))}</div>
{f'<div class="small text-danger mt-1">Técnico: {esc(short_err)}</div>' if short_err else ''}
</div>
</td>
<td class="text-end">
<div class="d-flex flex-wrap justify-content-end gap-1">
<a class="btn btn-sm btn-outline-secondary" href="/outbox/{oid}">Ver</a>
<form method="post" action="/outbox/{oid}/pending" hx-post="/outbox/{oid}/pending" hx-target="#outbox-table" hx-swap="outerHTML" hx-indicator="#outbox-loading" hx-confirm="Reprocessar este item da outbox?"><button class="btn btn-sm btn-outline-primary" type="submit">Reprocessar</button></form>
<form method="post" action="/outbox/{oid}/ignored" hx-post="/outbox/{oid}/ignored" hx-target="#outbox-table" hx-swap="outerHTML" hx-indicator="#outbox-loading" hx-confirm="Ignorar este item da outbox?"><button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button></form>
</div>
</td>
</tr>
'''
if not rows:
rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem itens de outbox para os filtros selecionados.</td></tr>'
return f'''
<div id="outbox-table" class="cf-live-panel" aria-live="polite">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<span class="small text-secondary">{len(items)} resultado(s)</span>
<span class="small text-secondary htmx-indicator" id="outbox-loading">A atualizar…</span>
</div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>Criado / ID</th><th>Estado</th><th>Sistema / Ação</th><th>Origem</th><th>Erro</th><th class="text-end">Ações</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
</div>
'''
@router.get("/outbox/partials/table", response_class=HTMLResponse)
async def outbox_table_partial(status: Optional[str] = None, target_system: Optional[str] = None, limit: int = 100):
status = status or "all"
target_system = target_system or "all"
items = _outbox_items(status=status, target_system=target_system, limit=limit)
return HTMLResponse(render_outbox_table_partial(items, status=status, target_system=target_system))
@router.get("/outbox", response_class=HTMLResponse)
async def outbox_page(
status: Optional[str] = None,
target_system: Optional[str] = None,
limit: int = 100,
):
status = status or "all"
target_system = target_system or "all"
items = _outbox_items(status=status, target_system=target_system, limit=limit)
status_filters = "".join(
f'<a class="btn btn-sm {"btn-primary" if status == value else "btn-outline-secondary"}" href="{_outbox_tab_url(status=value, target_system=target_system)}" hx-get="{_outbox_partial_url(status=value, target_system=target_system)}" hx-target="#outbox-table" hx-swap="outerHTML" hx-push-url="{_outbox_tab_url(status=value, target_system=target_system)}" hx-indicator="#outbox-loading">{esc(label)}</a>'
for value, label in [("all", "Todas"), ("pending", "Pendentes"), ("processing", "A processar"), ("sent", "Processadas"), ("failed", "Falhadas"), ("blocked", "Bloqueadas"), ("stale", "Stale"), ("dry_run", "Dry-run"), ("ignored", "Ignoradas"), ("cancelled", "Canceladas")]
)
system_filters = "".join(
f'<a class="btn btn-sm {"btn-primary" if target_system == value else "btn-outline-secondary"}" href="{_outbox_tab_url(status=status, target_system=value)}" hx-get="{_outbox_partial_url(status=status, target_system=value)}" hx-target="#outbox-table" hx-swap="outerHTML" hx-push-url="{_outbox_tab_url(status=status, target_system=value)}" hx-indicator="#outbox-loading">{esc(label)}</a>'
for value, label in [("all", "Todos"), ("jasmin", "Jasmin"), ("packlink", "Packlink"), ("chatwoot", "Chatwoot"), ("mautic", "Mautic"), ("odoo", "Odoo")]
)
body = f'''
<section class="card cf-card cf-filter-card">
<div class="card-body p-3 d-grid gap-3">
<div><strong class="me-2">Estado:</strong><span class="d-inline-flex flex-wrap gap-2">{status_filters}</span></div>
<div><strong class="me-2">Sistema:</strong><span class="d-inline-flex flex-wrap gap-2">{system_filters}</span></div>
</div>
</section>
<section class="card cf-card">
<div class="card-body p-0">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
<div><h2 class="cf-section-title">Outbox operacional</h2><div class="small text-secondary">Reprocessa ou ignora ações sem usar SQL no terminal. Filtros e ações atualizam por HTMX.</div></div>
<a class="btn btn-sm btn-outline-secondary" href="/integrations">Integrações</a>
</div>
{render_outbox_table_partial(items, status=status, target_system=target_system)}
</div>
</section>
'''
return layout("Outbox", "Ações pendentes e histórico de integrações externas", body, "outbox")
@router.get("/outbox/{outbox_id}", response_class=HTMLResponse)
async def outbox_detail(outbox_id: str):
item = get_outbox_item(outbox_id)
if not item:
return layout("Outbox item", "Item não encontrado.", "<p>Não encontrado.</p>", "outbox")
payload = item.get("payload") or {}
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
opp_id = payload.get("opportunity_id") or ""
origin_href = ("/opportunities/" + esc(opp_id)) if opp_id else "/outbox"
origin_label = "Oportunidade" if opp_id else ""
human_error = outbox_operator_message(item)
fix_href = origin_href if opp_id else "/outbox"
body = f'''
<p><a class="cf-row-link" href="/outbox">← Voltar à outbox</a></p>
<section class="cf-kpi-grid">
{kpi_card('Estado', esc(item.get('status')), None, 'atual', 'bi-info-circle')}
{kpi_card('Sistema', esc(item.get('target_system')), None, esc(item.get('action_type')), 'bi-puzzle')}
{kpi_card('Retries', esc(item.get('retry_count')), None, 'tentativas', 'bi-arrow-clockwise')}
{kpi_card('Origem', origin_label, origin_href, esc(opp_id), 'bi-link-45deg')}
</section>
<section class="card cf-card mb-3"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Leitura operacional</h2>
<div class="cf-outbox-human-error">
<strong>{esc(human_error.get('title'))}</strong>
<div class="small mt-1">Motivo provável: {esc(human_error.get('probable'))}</div>
<a class="btn btn-sm btn-outline-primary mt-3" href="{esc(fix_href)}">{esc(human_error.get('fix_label'))}</a>
</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Payload</h2>
<pre>{esc(pretty_json(payload))}</pre>
<h2 class="cf-section-title mb-3 mt-4">Erro técnico</h2>
<pre>{esc(item.get('last_error') or '')}</pre>
<div class="d-flex flex-wrap gap-2 mt-3">
<form method="post" action="/outbox/{esc(item['id'])}/pending" hx-confirm="Reprocessar este item da outbox?"><button class="btn btn-outline-primary" type="submit">Reprocessar</button></form>
<form method="post" action="/outbox/{esc(item['id'])}/ignored" hx-confirm="Ignorar este item da outbox?"><button class="btn btn-outline-secondary" type="submit">Ignorar</button></form>
<form method="post" action="/outbox/{esc(item['id'])}/failed" hx-confirm="Marcar este item como failed?"><button class="btn btn-outline-danger" type="submit">Marcar failed</button></form>
</div>
</div></section>
'''
return layout("Outbox item", f"Detalhe {outbox_id}", body, "outbox")
@router.post("/outbox/{outbox_id}/retry")
async def outbox_retry(outbox_id: str, request: Request):
form = await request.form()
opportunity_id = str(form.get("opportunity_id") or "").strip()
set_outbox_status(outbox_id=outbox_id, status="pending")
if opportunity_id and is_htmx(request):
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Item de outbox reposto para pending."))
if opportunity_id:
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Item%20de%20outbox%20reposto%20para%20pending", status_code=303)
return RedirectResponse("/outbox", status_code=303)
def _outbox_htmx_or_redirect(request: Request, status: str = "all", target_system: str = "all"):
if is_htmx(request):
return HTMLResponse(render_outbox_table_partial(_outbox_items(status=status, target_system=target_system)))
return RedirectResponse("/outbox", status_code=303)
@router.post("/outbox/{outbox_id}/pending")
async def outbox_pending(outbox_id: str, request: Request):
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"})
return _outbox_htmx_or_redirect(request)
@router.post("/outbox/{outbox_id}/sent")
async def outbox_sent(outbox_id: str, request: Request):
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"})
return _outbox_htmx_or_redirect(request)
@router.post("/outbox/{outbox_id}/failed")
async def outbox_failed(outbox_id: str, request: Request):
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"})
return _outbox_htmx_or_redirect(request)
@router.post("/outbox/{outbox_id}/ignored")
async def outbox_ignored(outbox_id: str, request: Request):
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"})
return _outbox_htmx_or_redirect(request)

View File

@@ -0,0 +1,301 @@
"""Product catalog and opportunity item routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/products", response_class=HTMLResponse)
@router.get("/produtos", response_class=HTMLResponse)
async def products_page(
q: Optional[str] = None,
category: Optional[str] = "all",
active: Optional[str] = "true",
):
products = list_products(q=q, category=category, active=("true" if active == "missing_jasmin" else active), limit=500)
if active == "missing_jasmin":
products = [p for p in products if not p.get("jasmin_sales_item")]
categories = list_product_categories()
category_options = '<option value="all">Todas</option>'
for cat in categories:
selected = "selected" if category == cat else ""
category_options += f'<option value="{esc(cat)}" {selected}>{esc(cat)}</option>'
active_options = ""
for value, label in [("true", "Ativos"), ("missing_jasmin", "Ativos sem Artigo Jasmin"), ("false", "Inativos"), ("all", "Todos")]:
selected = "selected" if (active or "true") == value else ""
active_options += f'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
rows = ""
for product in products:
rows += f'''
<tr>
<td><code>{esc(product.get('sku') or '')}</code></td>
<td><code>{esc(product.get('jasmin_sales_item') or '')}</code></td>
<td><a class="cf-row-link" href="/products/{esc(product.get('id'))}">{esc(product.get('name') or 'Produto')}</a><div class="small text-secondary text-break">{esc(product.get('description') or '')}</div></td>
<td>{esc(product.get('category') or '')}</td>
<td class="text-end fw-bold">{money_html(product.get('default_unit_price'))}</td>
<td class="text-end">{esc(product.get('vat_rate') or '23')}%</td>
<td>{product_status_badge(product.get('active'))}</td>
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/products/{esc(product.get('id'))}">Abrir</a></td>
</tr>
'''
if not rows:
rows = '<tr><td colspan="8" class="text-center text-secondary py-5">Sem produtos encontrados.</td></tr>'
body = f'''
<section class="cf-kpi-grid">
{kpi_card('Produtos listados', len(products), '/products', 'catálogo', 'bi-box-seam')}
{kpi_card('Ativos', sum(1 for p in products if p.get('active')), '/products?active=true', 'para orçamento', 'bi-check2-circle', 'cf-kpi-tone-green')}
{kpi_card('Categorias', len(categories), '/products?category=Carregadores', 'organização', 'bi-tags')}
{kpi_card('Novo produto', '+', '/products/new', 'adicionar', 'bi-plus-circle')}
</section>
<section class="card cf-card cf-filter-card">
<form method="get" action="/products" class="row g-3 align-items-end">
<div class="col-lg-5">
<label class="form-label small fw-bold text-secondary">Procurar produto</label>
<input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="SKU, nome, categoria...">
</div>
<div class="col-lg-3">
<label class="form-label small fw-bold text-secondary">Categoria</label>
<select class="form-select" name="category">{category_options}</select>
</div>
<div class="col-lg-2">
<label class="form-label small fw-bold text-secondary">Estado</label>
<select class="form-select" name="active">{active_options}</select>
</div>
<div class="col-lg-2 d-flex gap-2">
<button class="btn btn-primary flex-fill" type="submit">Filtrar</button>
<a class="btn btn-outline-secondary" href="/products">Limpar</a>
</div>
</form>
</section>
<section class="card cf-card">
<div class="card-body p-0">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<h2 class="cf-section-title">Catálogo de produtos</h2>
<div class="small text-secondary">Produtos e serviços usados nas oportunidades, orçamentos e encomendas.</div>
</div>
<div class="d-flex flex-wrap gap-2"><a class="btn btn-outline-primary btn-sm" href="/products/validate-jasmin">Validar artigos Jasmin</a><a class="btn btn-primary btn-sm" href="/products/new">+ Novo produto</a></div>
</div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>SKU/Odoo</th><th>Jasmin</th><th>Produto</th><th>Categoria</th><th class="text-end">Preço</th><th class="text-end">IVA</th><th>Estado</th><th></th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
</div>
</section>
'''
return layout("Produtos", "Catálogo simples sem variantes, usado para propostas e encomendas", body, "products")
@router.get("/products/validate-jasmin", response_class=HTMLResponse)
async def products_validate_jasmin_page():
products = list_products(active="true", limit=500)
rows = ""
checked = 0
ok_count = 0
missing_count = 0
invalid_count = 0
client = None
if settings.jasmin_enabled:
try:
from app.jasmin_client import JasminClient
client = JasminClient()
except Exception as exc:
rows += f'<tr><td colspan="5" class="text-danger">Erro de configuração Jasmin: {esc(exc)}</td></tr>'
for product in products:
jasmin_item = str(product.get("jasmin_sales_item") or "").strip()
status = "Sem Artigo Jasmin"
detail = ""
chip = "cf-chip-orange"
if not jasmin_item:
missing_count += 1
elif not client:
status = "Não testado"
detail = "Jasmin desativado ou configuração inválida"
chip = "cf-chip-gray"
else:
checked += 1
try:
info = await client.get_sales_item(jasmin_item)
if isinstance(info, dict) and info.get("itemKey"):
status = "OK"
detail = info.get("description") or info.get("itemKey") or ""
chip = "cf-chip-green"
ok_count += 1
else:
status = "Resposta inesperada"
detail = str(info)[:180]
chip = "cf-chip-red"
invalid_count += 1
except Exception as exc:
status = "Não existe / erro"
detail = str(exc)[:220]
chip = "cf-chip-red"
invalid_count += 1
rows += f'''
<tr>
<td><code>{esc(product.get('sku') or '')}</code></td>
<td><code>{esc(jasmin_item or '')}</code></td>
<td><a class="cf-row-link" href="/products/{esc(product.get('id'))}">{esc(product.get('name') or 'Produto')}</a></td>
<td><span class="cf-chip {chip}">{esc(status)}</span></td>
<td class="small text-secondary text-break">{esc(detail)}</td>
</tr>
'''
if not rows:
rows = '<tr><td colspan="5" class="text-center text-secondary py-5">Sem produtos ativos.</td></tr>'
body = f'''
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
<section class="cf-kpi-grid">
{kpi_card('OK', ok_count, None, 'artigos válidos', 'bi-check2-circle', 'cf-kpi-tone-green')}
{kpi_card('Sem artigo', missing_count, None, 'precisam mapeamento', 'bi-box-seam', 'cf-kpi-tone-orange')}
{kpi_card('Inválidos/erro', invalid_count, None, 'corrigir antes de orçamentar', 'bi-exclamation-triangle', 'cf-kpi-tone-red')}
{kpi_card('Testados', checked, None, 'consultas Jasmin', 'bi-patch-check')}
</section>
<section class="card cf-card"><div class="card-body p-0">
<div class="p-3 border-bottom"><h2 class="cf-section-title">Validação de artigos Jasmin</h2><div class="small text-secondary">Confirma se products.jasmin_sales_item existe no Jasmin.</div></div>
<div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>SKU/Odoo</th><th>Artigo Jasmin</th><th>Produto</th><th>Estado</th><th>Detalhe</th></tr></thead><tbody>{rows}</tbody></table></div>
</div></section>
'''
return layout("Validar artigos Jasmin", "Verificação de mapeamentos de produtos", body, "products")
@router.get("/products/new", response_class=HTMLResponse)
async def product_new_page():
body = f'''
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Novo produto</h2>
{product_form_html({}, action="/products", submit_label="Criar produto")}
</div></section>
'''
return layout("Novo produto", "Adicionar produto ou serviço ao catálogo", body, "products")
@router.post("/products")
async def product_create(request: Request):
data = dict(await request.form())
try:
product_id = create_product(data)
return RedirectResponse(f"/products/{product_id}", status_code=303)
except Exception as exc:
body = f'''
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
<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")
@router.get("/products/{product_id}", response_class=HTMLResponse)
async def product_detail_page(product_id: str):
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")
toggle_label = "Desativar" if product.get("active") else "Ativar"
toggle_active = "false" if product.get("active") else "true"
body = f'''
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
<div class="cf-two-col">
<section class="card cf-card"><div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<span class="text-secondary small fw-bold text-uppercase">Produto</span>
<h2 class="h3 fw-bold mb-1">{esc(product.get('name') or 'Produto')}</h2>
<div class="small text-secondary">SKU/Odoo <code>{esc(product.get('sku') or '')}</code> · Jasmin <code>{esc(product.get('jasmin_sales_item') or '')}</code> · {esc(product.get('category') or 'Geral')}</div>
</div>
{product_status_badge(product.get('active'))}
</div>
{product_form_html(product, action=f"/products/{product_id}/update", submit_label="Guardar alterações")}
</div></section>
<aside class="cf-side">
<section class="card cf-card"><div class="card-body p-3">
<h2 class="cf-section-title mb-3">Resumo</h2>
<div class="cf-soft-box mb-2"><div class="small text-secondary fw-bold">Preço base</div><strong>{money_html(product.get('default_unit_price'))}</strong></div>
<div class="cf-soft-box mb-2"><div class="small text-secondary fw-bold">IVA</div><strong>{esc(product.get('vat_rate') or '23')}%</strong></div>
<div class="cf-soft-box"><div class="small text-secondary fw-bold">Atualizado</div><strong>{esc(product.get('updated_at') or '')}</strong></div>
</div></section>
<section class="card cf-card"><div class="card-body p-3">
<h2 class="cf-section-title mb-3">Ações</h2>
<form method="post" action="/products/{esc(product_id)}/toggle" class="d-grid">
<input type="hidden" name="active" value="{esc(toggle_active)}">
<button class="btn btn-outline-secondary" type="submit">{esc(toggle_label)} produto</button>
</form>
</div></section>
</aside>
</div>
'''
return layout(str(product.get("name") or "Produto"), "Editar produto do catálogo", body, "products")
@router.post("/products/{product_id}/update")
async def product_update(product_id: str, request: Request):
data = dict(await request.form())
try:
update_product(product_id, data)
return RedirectResponse(f"/products/{product_id}", status_code=303)
except Exception as exc:
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">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")
@router.post("/products/{product_id}/toggle")
async def product_toggle(product_id: str, request: Request):
data = dict(await request.form())
set_product_active(product_id, str(data.get("active") or "false").lower() == "true")
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):
data = dict(await request.form())
try:
add_opportunity_item(
opportunity_id,
product_id=data.get("product_id") or None,
product_name=data.get("product_name") or None,
jasmin_sales_item=data.get("jasmin_sales_item") or None,
quantity=data.get("quantity") or "1",
unit_price=data.get("unit_price") or None,
discount_amount=data.get("discount_amount") or "0",
status=data.get("status") or "INTERESTED",
)
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)
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)
@router.post("/opportunities/{opportunity_id}/items/{item_id}/delete")
async def opportunity_item_delete(opportunity_id: str, item_id: str, request: Request):
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)
if is_htmx(request):
return HTMLResponse(opportunity_products_panel_html(opportunity_id, notice="Produto removido."))
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)

View File

@@ -0,0 +1,83 @@
"""Operational queue diagnostic routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/queues", response_class=HTMLResponse)
@router.get("/filas", response_class=HTMLResponse)
async def queues_page():
metrics = get_admin_dashboard_metrics()
queue_defs = [
("vendas", "Comercial", "Orçamentos, propostas e oportunidades", "pending_vendas", "cf-chip-blue"),
("financeiro", "Financeiro", "Pagamentos, faturas e comprovativos", "pending_financeiro", "cf-chip-green"),
("operacoes", "Logística", "Envios, recolhas e entregas", "pending_operacoes", "cf-chip-orange"),
("suporte", "Suporte", "Técnico, garantia e pós-venda", "pending_suporte", "cf-chip-purple"),
("rever", "Rever", "Casos ambíguos ou baixa confiança", "pending_rever", "cf-chip-gray"),
]
rows = ""
chart_items = ""
for route_key, label, description, metric_key, chip_class in queue_defs:
count = int(metrics.get(metric_key) or 0)
active_tasks = []
try:
active_tasks = list_tasks(status="pending", route=route_key, limit=1)
except Exception:
active_tasks = []
priority = "Alta" if route_key in {"financeiro", "operacoes"} and count else ("Média" if count else "Baixa")
rows += f'''
<tr>
<td><span class="cf-chip {chip_class}">{esc(label)}</span></td>
<td>{esc(description)}</td>
<td><strong>{count}</strong></td>
<td><span class="cf-chip {'cf-chip-red' if priority == 'Alta' else 'cf-chip-orange' if priority == 'Média' else 'cf-chip-gray'}">{esc(priority)}</span></td>
<td>
<a class="btn btn-outline-primary btn-sm" href="/tasks?status=pending&route={esc(route_key)}">Abrir</a>
</td>
</tr>
'''
chart_items += f'''
<div class="mb-2">
<div class="d-flex justify-content-between"><span>{esc(label)}</span><strong>{count}</strong></div>
<div class="progress" style="height:.55rem"><div class="progress-bar" style="width:{min(100, count * 4)}%"></div></div>
</div>
'''
body = f'''
<div class="row g-3">
<div class="col-xl-8">
<section class="card cf-card">
<div class="card-body p-0">
<div class="p-3 border-bottom">
<h2 class="cf-section-title">Filas</h2>
<div class="small text-secondary">Gestão de distribuição de tarefas por equipa.</div>
</div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>Fila</th><th>Descrição</th><th>Tarefas</th><th>Prioridade</th><th>Ações</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
</div>
</section>
</div>
<div class="col-xl-4">
<section class="card cf-card">
<div class="card-body p-4">
<h2 class="cf-section-title mb-3">Distribuição de tarefas</h2>
{chart_items}
</div>
</section>
</div>
</div>
'''
return layout("Filas", "Gestão de filas e distribuição de tarefas", body, "queues")

View File

@@ -0,0 +1,746 @@
"""Operational reconciliation and external intake UI.
This page is a staging area for information found outside ClientFlow. It does
not replace Operations; it prepares loose Jasmin/Odoo/payment/manual evidence so
an operator can link, create or ignore it deliberately.
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.admin_ui.components import esc, fmt_dt, kpi_card, money, status_chip
from app.admin_ui.layout import layout
from app.fiscal_enrichment_service import enrich_open_opportunities, fiscal_enrichment_summary
from app.external_reconciliation_sync import (
sync_all_external_reconciliation_candidates,
sync_jasmin_reconciliation_candidates,
sync_odoo_reconciliation_candidates,
sync_packlink_reconciliation_candidates,
)
from app.reconciliation_decision_service import (
classify_reconciliation_item,
classify_reconciliation_process,
reconciliation_decision_summary,
sort_items_for_operator,
)
from app.reconciliation_service import (
cleanup_reconciliation_outside_window,
create_external_request,
create_opportunity_from_reconciliation,
create_payment_proof,
link_reconciliation_to_opportunity,
list_reconciliation_items,
list_reconciliation_process_candidates,
create_opportunity_from_reconciliation_process,
link_reconciliation_process_to_opportunity,
reconciliation_summary,
recent_window_start,
reset_generated_reconciliation_items,
set_reconciliation_status,
sync_local_documents_without_opportunity,
upsert_reconciliation_item,
)
logger = logging.getLogger(__name__)
router = APIRouter()
TYPE_LABELS = {
"jasmin_quotation": "Orçamento Jasmin",
"jasmin_proforma": "Pró-forma Jasmin",
"jasmin_invoice": "Fatura Jasmin",
"odoo_sale_order": "Venda Odoo",
"payment_proof": "Comprovativo",
"packlink_shipment": "Envio Packlink",
"manual_request": "Pedido externo",
"external_record": "Registo externo",
}
def _type_label(value: str | None) -> str:
return TYPE_LABELS.get(str(value or ""), str(value or ""))
def _safe_days(value: object, default: int = 3) -> int:
try:
return min(max(int(value or default), 1), 90)
except Exception:
return default
def _filter_url(status: str = "open", external_type: str = "all", days: int = 3) -> str:
return f"/reconciliation?status={esc(status)}&external_type={esc(external_type)}&days={int(days or 3)}"
def _operation_label(value: str | None) -> str:
raw = str(value or "")
if not raw:
return ""
labels = {
"odoo_sale_order": "Venda Odoo",
"jasmin_quotation": "Orçamento Jasmin",
"jasmin_proforma": "Pró-forma Jasmin",
"jasmin_invoice": "Fatura Jasmin",
"document": "Documento",
}
if ":" in raw:
key, ref = raw.split(":", 1)
return f"{labels.get(key, key)} {ref}"
return raw
def _render_item_rows(items: list[dict]) -> str:
rows = ""
for item in items:
item_id = str(item.get("id") or "")
opportunity_link = ""
if item.get("opportunity_id"):
opportunity_label = item.get("opportunity_title") or "Ver oportunidade"
opportunity_link = f'<a href="/opportunities/{esc(item.get("opportunity_id"))}">{esc(opportunity_label)}</a>'
amount = ""
if item.get("amount") is not None:
amount = money(item.get("amount"), item.get("currency") or "EUR")
actions = ""
if str(item.get("status") or "") in {"open", "needs_review"}:
suggestions = item.get("operation_suggestions") or []
suggestion_html = ""
if suggestions:
suggestion_cards = ""
for suggestion in suggestions[:2]:
suggested_opp_id = str(suggestion.get("opportunity_id") or "")
suggested_title = suggestion.get("opportunity_title") or "Oportunidade aberta"
suggested_customer = suggestion.get("customer_name") or ""
suggested_action = suggestion.get("action") or suggestion.get("action_code") or "operação aberta"
suggested_reason = suggestion.get("reason") or "possível correspondência"
suggestion_cards += f"""
<form method="post" action="/reconciliation/{esc(item_id)}/link" hx-confirm="Ligar este item à operação/oportunidade sugerida?" class="cf-reconcile-suggestion">
<input type="hidden" name="opportunity_id" value="{esc(suggested_opp_id)}">
<div class="small text-secondary">Sugestão encontrada em Operations</div>
<div class="fw-semibold text-break">{esc(suggested_title)}</div>
<div class="small text-secondary text-break">{esc(suggested_customer)}</div>
<div class="small text-secondary text-break">{esc(suggested_action)} · {esc(suggested_reason)}</div>
<button class="btn btn-sm btn-primary mt-2" type="submit">Ligar a esta operação</button>
</form>
"""
suggestion_html = f'<div class="d-grid gap-2 mb-2">{suggestion_cards}</div>'
create_button_class = "btn-outline-primary" if suggestions else "btn-primary"
search_query = item.get("customer_tax_id") or item.get("customer_email") or item.get("customer_name") or item.get("document_number") or ""
search_href = f"/opportunities?q={esc(str(search_query))}" if search_query else "/opportunities"
manual_link = f"""
<details class="cf-reconcile-manual-link small text-secondary mt-1">
<summary>Escolher outra oportunidade</summary>
<form class="d-flex gap-1 mt-2" method="post" action="/reconciliation/{esc(item_id)}/link" hx-confirm="Ligar este item à oportunidade indicada?">
<input class="form-control form-control-sm" name="opportunity_id" placeholder="ID oportunidade" style="max-width:170px">
<button class="btn btn-sm btn-outline-primary" type="submit">Ligar</button>
</form>
</details>
"""
actions = f"""
<div class="d-grid gap-2 justify-content-end">
{suggestion_html}
<div class="d-flex flex-wrap justify-content-end gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{search_href}">Pesquisar oportunidade</a>
<form method="post" action="/reconciliation/{esc(item_id)}/create-opportunity" hx-confirm="Criar nova oportunidade a partir deste item? Use isto apenas se não houver operação aberta correspondente.">
<button class="btn btn-sm {create_button_class}" type="submit">Criar oportunidade</button>
</form>
<form method="post" action="/reconciliation/{esc(item_id)}/ignore" hx-confirm="Ignorar este item de reconciliação?">
<button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button>
</form>
<form method="post" action="/reconciliation/{esc(item_id)}/needs-review" hx-confirm="Enviar este item para revisão manual?">
<button class="btn btn-sm btn-outline-warning" type="submit">Rever</button>
</form>
<form method="post" action="/reconciliation/{esc(item_id)}/historical" hx-confirm="Marcar este item como histórico sem ação operacional?">
<button class="btn btn-sm btn-outline-secondary" type="submit">Histórico</button>
</form>
</div>
{manual_link}
</div>
"""
else:
actions = '<span class="text-secondary small">Sem ações pendentes</span>'
decision = classify_reconciliation_item(item)
decision_chip = f'<span class="cf-chip {esc(decision.get("chip_class") or "cf-chip-gray")}">{esc(decision.get("label") or "Decisão")}</span>'
rows += f"""
<tr>
<td>
<div class="fw-bold">{esc(item.get('title'))}</div>
<div class="small text-secondary text-break">{esc(item.get('description') or '')}</div>
<div class="small text-secondary mt-1">{esc(item.get('source_system'))} · {esc(item.get('external_id') or 'sem external_id')}</div>
</td>
<td><span class="cf-chip cf-chip-blue">{esc(_type_label(item.get('external_type')))}</span><div class="small mt-1">{esc(item.get('document_number') or '')}</div></td>
<td>{status_chip(item.get('status'))}<div class="small text-secondary mt-1">{esc(item.get('priority') or 'normal')}</div><div class="mt-1">{decision_chip}</div><div class="small text-secondary mt-1">{esc(decision.get('primary_decision') or '')}</div></td>
<td><div>{esc(item.get('customer_name') or item.get('linked_customer_name') or '')}</div><div class="small text-secondary">{esc(item.get('customer_email') or '')}</div>{f'<div class="small text-secondary">NIF {esc(item.get("customer_tax_id"))}</div>' if item.get('customer_tax_id') else ''}</td>
<td>{amount}<div class="small text-secondary">{esc(fmt_dt(item.get('document_date')))}</div></td>
<td>{opportunity_link}</td>
<td class="text-end">{actions}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="7" class="text-center text-secondary py-5">Sem itens de reconciliação para este filtro.</td></tr>'
return rows
def _render_process_candidates(candidates: list[dict], *, days: int = 3) -> str:
"""Render grouped timeline reconstruction proposals.
These cards are intentionally more useful than isolated rows: they show the
probable sequence found across Jasmin/Odoo/Packlink/payment evidence and let
the operator create or link one process deliberately.
"""
if not candidates:
return """
<section class="card cf-card mb-4">
<div class="card-body p-4 text-secondary">
<h2 class="cf-section-title mb-2">Processos candidatos</h2>
<div>Sem processos reconstruíveis na janela ativa. Itens isolados continuam disponíveis abaixo.</div>
</div>
</section>
"""
cards = ""
for candidate in candidates[:8]:
item_ids = ",".join(str(x) for x in candidate.get("item_ids") or [])
tax_line = f'<div class="small text-secondary">NIF {esc(candidate.get("customer_tax_id"))}</div>' if candidate.get("customer_tax_id") else ""
email_line = f'<div class="small text-secondary text-break">{esc(candidate.get("customer_email"))}</div>' if candidate.get("customer_email") else ""
amount = ""
if candidate.get("amount") is not None:
amount = f'<span class="cf-chip cf-chip-gray">{money(candidate.get("amount"), candidate.get("currency") or "EUR")}</span>'
identity_line = f'<div class="small text-secondary">Cliente fiscal: {esc(candidate.get("identity_reason") or candidate.get("match_key") or "")}</div>'
operation_label = _operation_label(candidate.get("operation_key"))
operation_line = f'<div class="small text-secondary">Compra/processo: {esc(operation_label)}</div>' if operation_label else ""
review_status = str(candidate.get("review_status") or "needs_review")
review_label = {"ready": "pronto", "needs_review": "rever", "conflict": "conflito"}.get(review_status, review_status)
review_class = "green" if review_status == "ready" else "orange" if review_status == "needs_review" else "red"
review_chip = f'<span class="cf-chip cf-chip-{review_class}">{esc(review_label)}</span>'
decision = classify_reconciliation_process(candidate)
decision_chip = f'<span class="cf-chip {esc(decision.get("chip_class") or "cf-chip-gray")}">{esc(decision.get("label") or "Decisão")}</span>'
reasons = "".join(f'<li>{esc(reason)}</li>' for reason in (candidate.get("reasons") or [])[:5])
risks = "".join(f'<li>{esc(risk)}</li>' for risk in (candidate.get("risks") or [])[:5])
explanation_html = (
'<div class="cf-reconcile-explain">'
f'<div><strong>Motivos</strong><ul>{reasons or "<li>candidato reconstruído por identidade fiscal/processo</li>"}</ul></div>'
f'<div><strong>Riscos</strong><ul>{risks or "<li>sem riscos relevantes detetados</li>"}</ul></div>'
'</div>'
)
timeline = ""
for step in candidate.get("timeline") or []:
timeline += f"""
<div class="cf-reconcile-timeline-step">
<div class="small text-secondary">{esc(fmt_dt(step.get('document_date')))}</div>
<div><strong>{esc(step.get('label'))}</strong></div>
<div class="small text-secondary text-break">{esc(step.get('document_number') or step.get('title') or '')}</div>
</div>
"""
suggestion_html = ""
suggestions = candidate.get("suggestions") or []
if suggestions:
suggestion = suggestions[0]
suggestion_html = f"""
<form method="post" action="/reconciliation/processes/link" hx-confirm="Ligar todos os itens deste processo à oportunidade sugerida?" class="cf-reconcile-suggestion">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<input type="hidden" name="opportunity_id" value="{esc(suggestion.get('opportunity_id') or '')}">
<div class="small text-secondary">Sugestão de oportunidade existente</div>
<div class="fw-semibold text-break">{esc(suggestion.get('opportunity_title') or 'Oportunidade aberta')}</div>
<div class="small text-secondary text-break">{esc(suggestion.get('customer_name') or '')} · {esc(suggestion.get('reason') or 'possível correspondência')}</div>
<button class="btn btn-sm btn-primary mt-2" type="submit">Ligar processo à sugestão</button>
</form>
"""
cards += f"""
<article class="cf-reconcile-process-card">
<div class="d-flex justify-content-between gap-3 align-items-start flex-wrap">
<div>
<h3 class="h6 mb-1">{esc(candidate.get('customer_name') or 'Processo externo')}</h3>
{email_line}{tax_line}
</div>
<div class="d-flex gap-2 flex-wrap justify-content-end">
<span class="cf-chip cf-chip-blue">{len(candidate.get('items') or [])} evidência(s)</span>
<span class="cf-chip cf-chip-orange">confiança {esc(candidate.get('confidence') or 'média')}</span>
{review_chip}
{decision_chip}
{amount}
</div>
</div>
{identity_line}{operation_line}
<div class="cf-reconcile-decision-note"><strong>{esc(decision.get('primary_decision') or 'Decisão pendente')}</strong><span>{esc(decision.get('description') or '')}</span></div>
{explanation_html}
<div class="cf-reconcile-process-state">
<div><span>Estado sugerido</span><strong>{esc(candidate.get('suggested_stage') or 'REVIEW')}</strong></div>
<div><span>Próxima ação</span><strong>{esc(candidate.get('suggested_action') or 'REVIEW_MANUALLY')}</strong></div>
</div>
<div class="cf-reconcile-timeline">{timeline}</div>
<div class="d-flex flex-wrap gap-2 align-items-start">
{suggestion_html}
<form method="post" action="/reconciliation/processes/create-opportunity" hx-confirm="Criar uma oportunidade reconstruída com esta timeline?">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<button class="btn btn-sm btn-outline-primary" type="submit">Criar oportunidade reconstruída</button>
</form>
<form method="post" action="/reconciliation/processes/needs-review" hx-confirm="Enviar este processo para revisão manual?">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<button class="btn btn-sm btn-outline-warning" type="submit">Rever</button>
</form>
<form method="post" action="/reconciliation/processes/historical" hx-confirm="Marcar este processo como histórico sem ação operacional?">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<button class="btn btn-sm btn-outline-secondary" type="submit">Histórico</button>
</form>
<form method="post" action="/reconciliation/processes/ignore" hx-confirm="Ignorar este processo? Use apenas quando pertence a histórico externo ou não deve criar ação operacional.">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button>
</form>
<details class="small text-secondary cf-reconcile-manual-link">
<summary>Ligar a outra oportunidade</summary>
<form method="post" action="/reconciliation/processes/link" class="d-flex gap-1 mt-2" hx-confirm="Ligar todos os itens deste processo à oportunidade indicada?">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<input class="form-control form-control-sm" name="opportunity_id" placeholder="ID oportunidade" style="max-width:180px">
<button class="btn btn-sm btn-outline-primary" type="submit">Ligar</button>
</form>
</details>
</div>
</article>
"""
return f"""
<section class="card cf-card mb-4">
<div class="card-body p-3 border-bottom">
<h2 class="cf-section-title mb-1">Processos candidatos</h2>
<div class="small text-secondary">O sistema agrupa primeiro por cliente fiscal e depois separa por compra/processo antes de criar ou ligar a oportunidade.</div>
</div>
<div class="card-body p-3 d-grid gap-3">{cards}</div>
</section>
"""
def render_reconciliation_decision_board(items: list[dict], candidates: list[dict], *, status: str, external_type: str, days: int) -> str:
summary = reconciliation_decision_summary(items, candidates)
cards = [
("actionable", "Ação recomendada", "Ligar/criar com evidência suficiente", "open"),
("review", "Requer revisão", "Conflitos ou baixa confiança", "needs_review"),
("historical", "Histórico", "Sem ação operacional", "historical"),
("ignored", "Ignorados", "Fora da fila", "ignored"),
("resolved", "Resolvidos", "Ligados ou fechados", "linked"),
]
html = ""
for key, label, description, target_status in cards:
active = " active" if ((key == "actionable" and status == "open") or status == target_status) else ""
href = _filter_url(target_status, external_type, days)
html += f'''
<a class="cf-decision-card{active}" href="{href}">
<span>{esc(label)}</span>
<strong>{int(summary.get(key, 0))}</strong>
<small>{esc(description)}</small>
</a>
'''
return f'''
<section class="cf-reconcile-decision-grid mb-3" aria-label="Resumo decisional de reconciliação">
{html}
</section>
'''
def render_reconciliation_table(items: list[dict]) -> str:
return f"""
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table align-middle">
<thead><tr><th>Item</th><th>Tipo</th><th>Estado</th><th>Cliente</th><th>Valor/data</th><th>Oportunidade</th><th class="text-end">Ações</th></tr></thead>
<tbody>{_render_item_rows(items)}</tbody>
</table>
</div>
"""
@router.get("/reconciliation", response_class=HTMLResponse)
@router.get("/reconciliacao", response_class=HTMLResponse)
async def reconciliation_page(status: Optional[str] = "open", external_type: Optional[str] = "all", notice: Optional[str] = None, days: Optional[int] = 3):
status = status or "open"
external_type = external_type or "all"
item_type = None if external_type == "all" else external_type
recent_days = min(max(int(days or 3), 1), 90)
items = sort_items_for_operator(list_reconciliation_items(status=status, external_type=item_type, limit=100, days=recent_days))
process_candidates = list_reconciliation_process_candidates(status="open", days=recent_days, limit=8)
summary = reconciliation_summary(days=recent_days)
enrichment_summary = fiscal_enrichment_summary()
notice_html = f'<div class="alert alert-info border-0">{esc(notice)}</div>' if notice else ""
status_filters = "".join(
f'<a class="btn btn-sm {"btn-primary" if status == value else "btn-outline-secondary"}" href="{_filter_url(value, external_type, recent_days)}">{esc(label)}</a>'
for value, label in [("open", "Abertos"), ("needs_review", "Revisão"), ("conflict", "Conflitos"), ("historical", "Histórico"), ("linked", "Ligados"), ("resolved", "Resolvidos"), ("ignored", "Ignorados"), ("all", "Todos")]
)
type_filters = "".join(
f'<a class="btn btn-sm {"btn-primary" if external_type == value else "btn-outline-secondary"}" href="{_filter_url(status, value, recent_days)}">{esc(label)}</a>'
for value, label in [("all", "Todos"), ("jasmin_quotation", "Orçamentos"), ("jasmin_invoice", "Faturas"), ("payment_proof", "Comprovativos"), ("odoo_sale_order", "Odoo"), ("packlink_shipment", "Packlink"), ("manual_request", "Externos")]
)
body = f"""
{notice_html}
<section class="alert alert-primary border-0 shadow-sm">
<strong>Reconciliação = informação solta para organizar.</strong>
O sistema cria candidatos por cliente fiscal e por compra/processo; o operador decide ligar, criar oportunidade ou ignorar. Janela ativa: últimos {recent_days} dias operacionais, desde {esc(recent_window_start(recent_days))}. Comprovativo recebido não confirma pagamento.
</section>
<section class="cf-kpi-grid">
{kpi_card('Itens abertos', summary.get('open', 0), '/reconciliation', 'pendências para ligar/criar/ignorar', 'bi-diagram-3')}
{kpi_card('Conflitos', summary.get('conflict', 0), '/reconciliation?status=conflict', 'cliente/processo com risco', 'bi-exclamation-triangle')}
{kpi_card('Histórico', summary.get('historical', 0), '/reconciliation?status=historical', 'fora da operação diária', 'bi-archive')}
{kpi_card('Resolvidos', summary.get('resolved', 0), '/reconciliation?status=linked', 'ligados ou fechados', 'bi-check2-circle')}
{kpi_card('Sugestões fiscais', enrichment_summary.get('pending_suggestions', 0), '/opportunities?scope=blocked', 'pendentes antes da reconciliação', 'bi-person-vcard')}
</section>
{render_reconciliation_decision_board(items, process_candidates, status=status, external_type=external_type, days=recent_days)}
<section class="card cf-card mb-3"><div class="card-body p-3 d-grid gap-3">
<div><strong class="me-2">Estado:</strong><span class="d-inline-flex flex-wrap gap-2">{status_filters}</span></div>
<div><strong class="me-2">Tipo:</strong><span class="d-inline-flex flex-wrap gap-2">{type_filters}</span></div>
<div><strong class="me-2">Janela:</strong><span class="d-inline-flex flex-wrap gap-2">
{''.join(f'<a class="btn btn-sm {"btn-primary" if recent_days == value else "btn-outline-secondary"}" href="/reconciliation?status={esc(status)}&external_type={esc(external_type)}&days={value}">{label}</a>' for value, label in [(1, 'Hoje'), (3, '3 dias'), (7, '7 dias'), (30, '30 dias')])}
</span></div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<form class="d-inline-block" method="post" action="/reconciliation/sync-local-documents" hx-confirm="Procurar documentos locais sem oportunidade?">
<button class="btn btn-sm btn-outline-primary" type="submit">Procurar documentos locais</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/sync-jasmin" hx-confirm="Consultar API Jasmin e criar candidatos de reconciliação?">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-primary" type="submit">Sincronizar Jasmin</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/sync-odoo" hx-confirm="Consultar API Odoo e criar candidatos de reconciliação?">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-primary" type="submit">Sincronizar Odoo</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/sync-packlink" hx-confirm="Consultar API Packlink e criar candidatos de reconciliação?">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-primary" type="submit">Sincronizar Packlink</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/enrich-fiscal" hx-confirm="Enriquecer oportunidades abertas sem cliente fiscal antes da reconciliação? Auto-associa apenas matches muito fortes.">
<button class="btn btn-sm btn-outline-success" type="submit">Enriquecer oportunidades</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/sync-external" hx-confirm="Consultar APIs externas ativas na janela ativa? A sequência é: enriquecimento fiscal → clientes fiscais → documentos Jasmin → vendas Odoo → envios Packlink. Não cria oportunidades automaticamente.">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-primary" type="submit">Sincronizar APIs externas</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/rebuild" hx-confirm="Reconstruir a reconciliação da janela ativa? Vai apagar apenas candidatos gerados por Jasmin/Odoo/Packlink sem oportunidade ligada, criar backup e correr a sequência enriquecimento fiscal → clientes fiscais → Jasmin → Odoo → faturas/envios. Não apaga clientes, oportunidades nem documentos reais.">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-danger" type="submit">Apagar e correr novamente</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/cleanup-window" hx-confirm="Limpar da fila aberta os candidatos fora da janela ativa? Não apaga documentos externos.">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-danger" type="submit">Limpar fora dos 3 dias/janela</button>
</form>
</div>
</div></section>
{_render_process_candidates(process_candidates, days=recent_days)}
<section class="card cf-card mb-4">
<div class="card-body p-0">
<div class="p-3 border-bottom"><h2 class="cf-section-title mb-1">Itens soltos</h2><div class="small text-secondary">Itens individuais que ainda precisam de decisão ou que não formaram um processo candidato.</div></div>
{render_reconciliation_table(items)}
</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">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><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>
</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">Adicionar comprovativo</h2>
<form method="post" action="/reconciliation/payment-proof" class="d-grid gap-3">
<div class="alert alert-warning border-0 py-2 mb-0"><strong>Regra:</strong> comprovativo recebido abre validação financeira; não confirma pagamento.</div>
<div class="row g-2"><div class="col-md-7"><label class="form-label small fw-bold">ID oportunidade, se conhecido</label><input class="form-control" name="opportunity_id" placeholder="opcional"></div><div class="col-md-5"><label class="form-label small fw-bold">Origem</label><select class="form-select" name="source_system"><option>WhatsApp</option><option>Chatwoot</option><option>Email</option><option>Manual</option></select></div></div>
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Valor indicado</label><input class="form-control" name="amount" placeholder="190,00"></div><div class="col-md-6"><label class="form-label small fw-bold">Ficheiro/ref.</label><input class="form-control" name="filename" placeholder="comprovativo.jpg"></div></div>
<div><label class="form-label small fw-bold">Nota</label><textarea class="form-control" rows="3" name="note" placeholder="Referência, banco, MB Way, observações..."></textarea></div>
<div><button class="btn btn-primary" type="submit">Guardar comprovativo</button></div>
</form>
</div></section>
</div>
</div>
<section class="card cf-card mt-3"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Criar candidato manual de reconciliação</h2>
<form method="post" action="/reconciliation/item" class="row g-2 align-items-end">
<div class="col-md-2"><label class="form-label small fw-bold">Sistema</label><select class="form-select" name="source_system"><option>jasmin</option><option>odoo</option><option>manual</option></select></div>
<div class="col-md-2"><label class="form-label small fw-bold">Tipo</label><select class="form-select" name="external_type"><option value="jasmin_quotation">Orçamento</option><option value="jasmin_invoice">Fatura</option><option value="odoo_sale_order">Venda Odoo</option><option value="external_record">Outro</option></select></div>
<div class="col-md-2"><label class="form-label small fw-bold">Nº documento</label><input class="form-control" name="document_number"></div>
<div class="col-md-2"><label class="form-label small fw-bold">Cliente</label><input class="form-control" name="customer_name"></div>
<div class="col-md-2"><label class="form-label small fw-bold">Valor</label><input class="form-control" name="amount"></div>
<div class="col-md-2"><button class="btn btn-outline-primary w-100" type="submit">Criar candidato</button></div>
</form>
</div></section>
"""
return layout("Reconciliação", "Organizar documentos, vendas e comprovativos fora do ClientFlow", body, "reconciliation")
@router.post("/reconciliation/sync-local-documents")
async def reconciliation_sync_local_documents():
result = sync_local_documents_without_opportunity(limit=200)
notice = f"Documentos analisados: {result.get('seen', 0)} · candidatos criados/atualizados: {result.get('created_or_updated', 0)}"
return RedirectResponse(f"/reconciliation?notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/sync-jasmin")
async def reconciliation_sync_jasmin(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
result = await sync_jasmin_reconciliation_candidates(limit=50, days=days)
notice = f"Jasmin últimos {result.get('days', days)} dias: analisados {result.get('seen', 0)} · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
if result.get("errors"):
notice += " · erros: " + "; ".join(str(x) for x in result.get("errors", [])[:2])
if result.get("skipped"):
notice += " · " + str(result.get("skipped"))
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/sync-odoo")
async def reconciliation_sync_odoo(request: Request):
form = await request.form()
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("skipped"):
notice += " · " + str(result.get("skipped"))
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/sync-packlink")
async def reconciliation_sync_packlink(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
result = await sync_packlink_reconciliation_candidates(limit=50, days=days)
notice = f"Packlink últimos {result.get('days', days)} dias: analisados {result.get('seen', 0)} envios · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
if result.get("errors"):
notice += " · erros: " + "; ".join(str(x) for x in result.get("errors", [])[:2])
if result.get("skipped"):
notice += " · " + str(result.get("skipped"))
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/enrich-fiscal")
async def reconciliation_enrich_fiscal(request: Request):
result = enrich_open_opportunities(limit=200, apply_safe=True, mode="operator_ui")
notice = (
f"Enriquecimento fiscal: analisadas {result.get('seen', 0)} oportunidades"
f" · sugestões {result.get('suggested', 0)}"
f" · auto-associadas {result.get('auto_applied', 0)}"
f" · ignoradas {result.get('skipped', 0)}"
)
if result.get("errors"):
notice += " · erros: " + "; ".join(str(x) for x in result.get("errors", [])[:2])
return RedirectResponse(f"/reconciliation?notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/sync-external")
async def reconciliation_sync_external(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
result = await sync_all_external_reconciliation_candidates(limit=50, days=days)
notice = (
f"APIs externas últimos {days} dias: enriquecidas {result.get('enrichment_auto_applied', 0)} oportunidades"
f" · sugestões fiscais {result.get('enrichment_suggested', 0)}"
f" · clientes fiscais analisados {result.get('customer_seen', 0)}"
f" · clientes criados/atualizados {result.get('customers_created_or_updated', 0)}"
f" · documentos/vendas analisados {result.get('seen', 0)}"
f" · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
)
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/rebuild")
async def reconciliation_rebuild(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
reset = reset_generated_reconciliation_items(days=days, limit=2000, actor="operator_ui", apply=True)
result = await sync_all_external_reconciliation_candidates(limit=200, days=days)
notice = (
f"Reconciliação reconstruída para {days} dias: apagados {reset.get('deleted', 0)} candidatos gerados"
f" · backup {reset.get('backup_table') or 'sem alterações'}"
f" · enriquecidas {result.get('enrichment_auto_applied', 0)} oportunidades"
f" · sugestões fiscais {result.get('enrichment_suggested', 0)}"
f" · clientes fiscais analisados {result.get('customer_seen', 0)}"
f" · clientes criados/atualizados {result.get('customers_created_or_updated', 0)}"
f" · documentos/vendas analisados {result.get('seen', 0)}"
f" · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
)
errors = []
for source_result in result.get("results", []) or []:
errors.extend(str(err) for err in source_result.get("errors", []) or [])
if errors:
notice += " · erros: " + "; ".join(errors[:2])
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/cleanup-window")
async def reconciliation_cleanup_window(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
result = cleanup_reconciliation_outside_window(days=days, limit=2000, actor="operator_ui")
notice = f"Limpeza aplicada: {result.get('ignored', 0)} itens fora dos últimos {result.get('days', days)} dias foram marcados como ignorados."
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/manual-request")
async def reconciliation_manual_request(request: Request):
form = await request.form()
result = create_external_request(
source_channel=str(form.get("source_channel") or "manual"),
customer_name=str(form.get("customer_name") or ""),
customer_email=str(form.get("customer_email") or ""),
customer_phone=str(form.get("customer_phone") or ""),
product_interest=str(form.get("product_interest") or ""),
request_text=str(form.get("request_text") or ""),
action_code=str(form.get("action_code") or "SEND_QUOTE"),
)
opportunity_id = result.get("opportunity_id")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20externo%20registado", status_code=303)
@router.post("/reconciliation/payment-proof")
async def reconciliation_payment_proof(request: Request):
form = await request.form()
proof = create_payment_proof(
opportunity_id=str(form.get("opportunity_id") or "").strip() or None,
source_system=str(form.get("source_system") or "manual"),
source_ref=str(form.get("source_ref") or ""),
filename=str(form.get("filename") or ""),
amount=str(form.get("amount") or ""),
note=str(form.get("note") or ""),
)
if proof.get("task_id") and proof.get("opportunity_id"):
return RedirectResponse(f"/opportunities/{proof.get('opportunity_id')}?notice=Comprovativo%20registado%20para%20validação", status_code=303)
return RedirectResponse("/reconciliation?external_type=payment_proof&notice=Comprovativo%20por%20associar%20registado", status_code=303)
@router.post("/reconciliation/item")
async def reconciliation_create_item(request: Request):
form = await request.form()
source_system = str(form.get("source_system") or "manual")
external_type = str(form.get("external_type") or "external_record")
document_number = str(form.get("document_number") or "").strip()
customer_name = str(form.get("customer_name") or "").strip()
title = f"{_type_label(external_type)} sem ligação"
if document_number:
title += f" · {document_number}"
upsert_reconciliation_item(
source_system=source_system,
external_type=external_type,
external_id=document_number or None,
title=title,
description="Candidato criado manualmente para ligação/criação de oportunidade.",
customer_name=customer_name,
document_number=document_number,
amount=str(form.get("amount") or ""),
suggested_action="CONFIRM_PAYMENT" if external_type == "jasmin_invoice" else "SEND_PROFORMA",
)
return RedirectResponse("/reconciliation?notice=Candidato%20criado", status_code=303)
@router.post("/reconciliation/processes/create-opportunity")
async def reconciliation_create_process_opportunity(request: Request):
form = await request.form()
item_ids = [part.strip() for part in str(form.get("item_ids") or "").split(",") if part.strip()]
opportunity_id = create_opportunity_from_reconciliation_process(item_ids)
if opportunity_id:
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Oportunidade%20reconstruída%20a%20partir%20da%20reconciliação", status_code=303)
return RedirectResponse("/reconciliation?notice=Não%20foi%20possível%20reconstruir%20oportunidade", status_code=303)
@router.post("/reconciliation/processes/link")
async def reconciliation_link_process(request: Request):
form = await request.form()
item_ids = [part.strip() for part in str(form.get("item_ids") or "").split(",") if part.strip()]
opportunity_id = str(form.get("opportunity_id") or "").strip()
if opportunity_id and item_ids:
try:
count = link_reconciliation_process_to_opportunity(item_ids, opportunity_id)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={count}%20itens%20de%20processo%20ligados", status_code=303)
except Exception as exc: # pragma: no cover - production guard
logger.exception("failed to link reconstructed reconciliation process to opportunity %s", opportunity_id)
return RedirectResponse(
"/reconciliation?notice=Erro%20ao%20ligar%20processo.%20Ver%20journal%20do%20servi%C3%A7o.",
status_code=303,
)
return RedirectResponse("/reconciliation?notice=Indica%20o%20ID%20da%20oportunidade", status_code=303)
async def _set_reconciliation_process_status(request: Request, *, status: str, note: str, notice: str):
form = await request.form()
item_ids = [part.strip() for part in str(form.get("item_ids") or "").split(",") if part.strip()]
for item_id in item_ids:
set_reconciliation_status(item_id, status=status, note=note)
return RedirectResponse(f"/reconciliation?status={status}&notice={notice}", status_code=303)
@router.post("/reconciliation/processes/needs-review")
async def reconciliation_process_needs_review(request: Request):
return await _set_reconciliation_process_status(
request,
status="needs_review",
note="Processo enviado para revisão manual.",
notice="Processo%20enviado%20para%20revis%C3%A3o",
)
@router.post("/reconciliation/processes/historical")
async def reconciliation_process_historical(request: Request):
return await _set_reconciliation_process_status(
request,
status="historical",
note="Processo marcado como histórico sem ação operacional.",
notice="Processo%20marcado%20como%20hist%C3%B3rico",
)
@router.post("/reconciliation/processes/ignore")
async def reconciliation_process_ignore(request: Request):
return await _set_reconciliation_process_status(
request,
status="ignored",
note="Processo ignorado manualmente pelo operador.",
notice="Processo%20ignorado",
)
@router.post("/reconciliation/{item_id}/needs-review")
async def reconciliation_needs_review(item_id: str):
set_reconciliation_status(item_id, status="needs_review", note="Enviado para revisão manual.")
return RedirectResponse("/reconciliation?status=needs_review&notice=Item%20enviado%20para%20revis%C3%A3o", status_code=303)
@router.post("/reconciliation/{item_id}/ignore")
async def reconciliation_ignore(item_id: str):
set_reconciliation_status(item_id, status="ignored", note="Ignorado manualmente.")
return RedirectResponse("/reconciliation?notice=Item%20ignorado", status_code=303)
@router.post("/reconciliation/{item_id}/historical")
async def reconciliation_historical(item_id: str):
set_reconciliation_status(item_id, status="historical", note="Marcado como histórico sem ação operacional.")
return RedirectResponse("/reconciliation?status=historical&notice=Item%20marcado%20como%20hist%C3%B3rico", status_code=303)
@router.post("/reconciliation/{item_id}/create-opportunity")
async def reconciliation_create_opportunity(item_id: str):
opportunity_id = create_opportunity_from_reconciliation(item_id)
if opportunity_id:
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Oportunidade%20criada%20a%20partir%20de%20reconciliação", status_code=303)
return RedirectResponse("/reconciliation?notice=Não%20foi%20possível%20criar%20oportunidade", status_code=303)
@router.post("/reconciliation/{item_id}/link")
async def reconciliation_link(item_id: str, request: Request):
form = await request.form()
opportunity_id = str(form.get("opportunity_id") or "").strip()
if opportunity_id:
link_reconciliation_to_opportunity(item_id, opportunity_id)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Item%20de%20reconciliação%20ligado", status_code=303)
return RedirectResponse("/reconciliation?notice=Indica%20o%20ID%20da%20oportunidade", status_code=303)

View File

@@ -0,0 +1,57 @@
"""Action run routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/runs", response_class=HTMLResponse)
async def runs_page(limit: int = 100):
runs = list_action_runs(limit=limit)
rows = ""
for run in runs:
decision = run.get("action_decision") or {}
result = run.get("action_result") or {}
rows += f"""
<tr>
<td>{esc(run["created_at"])}</td>
<td>#{esc(run.get("conversation_id"))}</td>
<td>{esc(run.get("decision_source"))}</td>
<td>{esc(decision.get("action_code"))}</td>
<td>{esc(result.get("route"))}</td>
<td>{esc(run.get("provider"))}</td>
<td>{esc(run.get("total_tokens"))}</td>
<td>{esc(run.get("cost"))}</td>
<td>{status_badge("failed" if run.get("needs_review") else "sent")}</td>
</tr>
"""
body = f"""
<table>
<thead>
<tr>
<th>Criado</th>
<th>Conversa</th>
<th>Fonte</th>
<th>Ação</th>
<th>Rota</th>
<th>Provider</th>
<th>Tokens</th>
<th>Custo</th>
<th>Review</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
"""
return layout("Action Runs", "Histórico de decisões do Action Core.", body, "runs")

View File

@@ -0,0 +1,300 @@
"""Settings and system health routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/system", response_class=HTMLResponse)
async def system_health_page():
metrics = get_system_health_metrics()
backup = latest_backup_info()
timers_raw = shell_output(["systemctl", "list-timers", "--all", "--no-pager"])
clientflow_timers = "\n".join(
line for line in timers_raw.splitlines()
if "clientflow" in line.lower()
)
services = [
"clientflow-api.service",
"clientflow-outbox.timer",
"clientflow-backup.timer",
"clientflow-followups.timer",
]
service_rows = ""
for service in services:
active = shell_output(["systemctl", "is-active", service])
enabled = shell_output(["systemctl", "is-enabled", service])
ok = active in ["active", "inactive"] if service.endswith(".service") else active == "active"
if service == "clientflow-api.service":
ok = active == "active"
service_rows += f"""
<tr>
<td><code>{esc(service)}</code></td>
<td>{status_pill(ok, active)}</td>
<td>{esc(enabled)}</td>
</tr>
"""
def n(key):
return metrics.get(key) or 0
db_ok = True
outbox_ok = n("outbox_failed") == 0
webhook_ok = n("webhooks_errors_24h") == 0
backup_ok = bool(backup.get("exists"))
body = f"""
<style>
.system-grid {{
display:grid;
grid-template-columns:repeat(4, minmax(180px, 1fr));
gap:14px;
margin-bottom:18px;
}}
.system-card {{
background:white;
border:1px solid #e5e7eb;
border-radius:16px;
padding:16px;
}}
.system-card span {{
color:#6b7280;
font-size:13px;
}}
.system-card strong {{
display:block;
font-size:28px;
margin-top:6px;
}}
.system-panel {{
background:white;
border:1px solid #e5e7eb;
border-radius:16px;
padding:16px;
margin-bottom:16px;
}}
.system-panel h2 {{
margin-top:0;
font-size:18px;
}}
.system-pill {{
display:inline-block;
border-radius:999px;
padding:4px 9px;
font-size:12px;
font-weight:700;
}}
.pill-ok {{
background:#dcfce7;
color:#166534;
border:1px solid #86efac;
}}
.pill-bad {{
background:#fee2e2;
color:#991b1b;
border:1px solid #fecaca;
}}
pre.system-pre {{
background:#111827;
color:#f9fafb;
border-radius:12px;
padding:12px;
overflow:auto;
font-size:12px;
}}
@media (max-width:1000px) {{
.system-grid {{ grid-template-columns:1fr 1fr; }}
}}
</style>
<p>
<a href="/">← Voltar à visão geral</a>
·
<a href="/system">Sistema</a>
<a href="/tasks">Kanban</a>
</p>
<div class="system-grid">
<div class="system-card">
<span>API</span>
<strong>{status_pill(True, "OK")}</strong>
</div>
<div class="system-card">
<span>Base de dados</span>
<strong>{status_pill(db_ok, "OK" if db_ok else "ERRO")}</strong>
</div>
<div class="system-card">
<span>Backups</span>
<strong>{status_pill(backup_ok, "OK" if backup_ok else "FALTA")}</strong>
</div>
<div class="system-card">
<span>Webhooks erros 24h</span>
<strong>{n("webhooks_errors_24h")}</strong>
</div>
</div>
<div class="system-grid">
<div class="system-card"><span>Tasks pendentes</span><strong>{n("tasks_pending")}</strong></div>
<div class="system-card"><span>Tasks 24h</span><strong>{n("tasks_24h")}</strong></div>
<div class="system-card"><span>Webhooks 24h</span><strong>{n("webhooks_24h")}</strong></div>
<div class="system-card"><span>Outbox falhada</span><strong>{n("outbox_failed")}</strong></div>
</div>
<div class="system-panel">
<h2>Estado dos serviços/timers</h2>
<table>
<thead>
<tr>
<th>Serviço</th>
<th>Active</th>
<th>Enabled</th>
</tr>
</thead>
<tbody>{service_rows}</tbody>
</table>
</div>
<div class="system-panel">
<h2>Último backup</h2>
<table>
<tbody>
<tr><th>Existe</th><td>{status_pill(backup_ok, "Sim" if backup_ok else "Não")}</td></tr>
<tr><th>Ficheiro</th><td><code>{esc(backup.get("file"))}</code></td></tr>
<tr><th>Tamanho</th><td>{esc(backup.get("size"))}</td></tr>
<tr><th>Modificado</th><td>{esc(backup.get("mtime"))}</td></tr>
</tbody>
</table>
</div>
<div class="system-panel">
<h2>Timers ClientFlow</h2>
<pre class="system-pre">{esc(clientflow_timers or "Sem timers ClientFlow encontrados.")}</pre>
</div>
<div class="system-panel">
<h2>Resumo técnico</h2>
<table>
<tbody>
<tr><th>Total tasks</th><td>{n("tasks_total")}</td></tr>
<tr><th>Tasks done</th><td>{n("tasks_done")}</td></tr>
<tr><th>Tasks failed</th><td>{n("tasks_failed")}</td></tr>
<tr><th>Webhooks ignorados 24h</th><td>{n("webhooks_ignored_24h")}</td></tr>
<tr><th>Outbox pending</th><td>{n("outbox_pending")}</td></tr>
<tr><th>Outbox sent</th><td>{n("outbox_sent")}</td></tr>
</tbody>
</table>
</div>
"""
return layout("Sistema", "Estado técnico do ClientFlow e integrações.", body, "system")
@router.get("/settings", response_class=HTMLResponse)
@router.get("/configuracoes", response_class=HTMLResponse)
async def settings_page():
return RedirectResponse("/system", status_code=303)
@router.get("/system/health", response_class=HTMLResponse)
async def system_health_operational_page():
health = get_system_health_summary()
settings_info = health.get("settings") or {}
timers = health.get("timers") or {}
outbox = health.get("outbox") or {}
documents = health.get("documents") or {}
operational_metrics = health.get("operational_metrics") or {}
def m(key: str) -> int:
try:
return int(operational_metrics.get(key) or 0)
except Exception:
return 0
timer_rows = ""
for name, item in timers.items():
ok = bool(item.get("ok"))
chip = "cf-chip-green" if ok else "cf-chip-orange"
timer_rows += f"""
<tr><td>{esc(name)}</td><td><code>{esc(item.get('unit') or '')}</code></td><td><span class="cf-chip {chip}">{esc(item.get('active_state') or 'unknown')}</span></td></tr>
"""
outbox_rows = ""
for target, by_status in outbox.items():
cells = "".join(f'<span class="cf-chip cf-chip-gray me-1">{esc(status)}: {esc(total)}</span>' for status, total in by_status.items())
outbox_rows += f"<tr><td>{esc(target)}</td><td>{cells}</td></tr>"
if not outbox_rows:
outbox_rows = '<tr><td colspan="2" class="text-secondary py-4">Sem dados de outbox.</td></tr>'
doc_rows = ""
for kind, by_status in documents.items():
cells = "".join(f'<span class="cf-chip cf-chip-gray me-1">{esc(status)}: {esc(total)}</span>' for status, total in by_status.items())
doc_rows += f"<tr><td>{esc(kind)}</td><td>{cells}</td></tr>"
if not doc_rows:
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")
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"
production_class = "critical"
production_hint = "corrigir bloqueios antes de automatizar"
elif warning_count:
production_state = "Atenção"
production_class = "warn"
production_hint = "existem validações operacionais pendentes"
else:
production_state = "OK"
production_class = "ok"
production_hint = "sem bloqueios críticos visíveis"
body = f"""
<section class="cf-health-status {production_class} mb-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<div><div class="small text-secondary fw-bold text-uppercase">Estado para operação</div><strong class="h3 mb-0">{production_state}</strong><div class="small text-secondary">{production_hint}</div></div>
<div class="d-flex flex-wrap gap-2"><span class="cf-chip cf-chip-red">Críticos: {esc(critical_count)}</span><span class="cf-chip cf-chip-orange">Atenção: {esc(warning_count)}</span></div>
</div>
</section>
<section class="cf-kpi-grid">
{kpi_card('Estado geral', esc(health.get('status')), None, 'OK' if db_ok else 'ver base de dados', 'bi-heart-pulse', 'cf-kpi-tone-green' if db_ok else 'cf-kpi-tone-red')}
{kpi_card('Jasmin', 'ativo' if settings_info.get('jasmin_enabled') else 'inativo', None, f"{esc(settings_info.get('jasmin_company_key') or '')} · {esc(settings_info.get('jasmin_quotation_serie') or '')}", 'bi-receipt')}
{kpi_card('Packlink', 'ativo' if settings_info.get('packlink_enabled') else 'inativo', None, f"serviço {esc(settings_info.get('packlink_default_service_id') or '')}", 'bi-truck')}
{kpi_card('Ambiente', esc(settings_info.get('env') or ''), None, esc(settings_info.get('app_name') or 'ClientFlow'), 'bi-sliders')}
</section>
<section class="cf-kpi-grid mt-3">
{kpi_card('Tasks pendentes', esc(m('tasks_pending')), '/operations', f"done 24h: {esc(m('tasks_done_24h'))}", 'bi-list-task')}
{kpi_card('Ambíguas', esc(m('ambiguous_opportunity_tasks')), '/operations?scope=ambiguas', 'associação por confirmar', 'bi-person-exclamation', 'cf-kpi-tone-orange' if m('ambiguous_opportunity_tasks') else 'cf-kpi-tone-green')}
{kpi_card('Outbox stale', esc(m('outbox_stale') or m('outbox_processing_stale')), '/outbox?status=stale', f"modo: {esc(settings_info.get('outbox_stale_recovery_mode') or 'manual_only')}", 'bi-hourglass-split', 'cf-kpi-tone-red' if (m('outbox_stale') or m('outbox_processing_stale')) else 'cf-kpi-tone-green')}
{kpi_card('Ações operador 24h', esc(m('operator_actions_24h')), '/events', 'auditoria operacional', 'bi-shield-check')}
</section>
<section class="cf-kpi-grid mt-3">
{kpi_card('Oportunidades sem cliente fiscal', esc(m('open_opportunities_without_fiscal_customer')), '/operations?scope=ambiguas', 'associação por confirmar', 'bi-person-vcard', 'cf-kpi-tone-orange' if m('open_opportunities_without_fiscal_customer') else 'cf-kpi-tone-green')}
{kpi_card('Clientes fiscais incompletos', esc(m('active_incomplete_fiscal_customers')), '/customers', 'em oportunidades ativas', 'bi-exclamation-diamond', 'cf-kpi-tone-orange' if m('active_incomplete_fiscal_customers') else 'cf-kpi-tone-green')}
{kpi_card('Produtos sem código externo', esc(m('products_missing_external_code')), '/products', 'podem bloquear Jasmin', 'bi-box-seam', 'cf-kpi-tone-orange' if m('products_missing_external_code') else 'cf-kpi-tone-green')}
{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>
<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>
<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">Documentos comerciais</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Tipo</th><th>Estados</th></tr></thead><tbody>{doc_rows}</tbody></table></div></div></section></div>
<div class="col-xl-6"><section class="card cf-card"><div class="card-body"><h2 class="cf-section-title mb-3">Base de dados</h2><p class="mb-2">Estado: <span class="cf-chip {'cf-chip-green' if db_ok else 'cf-chip-red'}">{'OK' if db_ok else 'Erro'}</span></p><pre class="cf-copy-box">{esc((health.get('database') or {}).get('error') or 'Sem erros reportados.')}</pre></div></section></div>
</div>
"""
return layout("Saúde operacional", "Base de dados, integrações, timers e contadores", body, "system")

769
app/admin_ui/pages/tasks.py Normal file
View File

@@ -0,0 +1,769 @@
"""Task list, detail and task action routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.admin_ui.guidance import (
fiscal_contact_panel_html,
fiscal_customer_missing_fields,
readiness_checklist_html,
)
router = APIRouter()
def _safe_task_display_html(value: str) -> str:
"""Avoid false-positive technical error markers in email/task content.
Some postmaster/Mail Delivery emails legitimately contain strings such as
"Exception:". The audit script treats those as runtime errors, so the UI
neutralizes the marker while preserving the meaning for the operator.
"""
text = str(value or "")
replacements = {
"Traceback (most recent call last)": "Relatório técnico remoto",
"Internal Server Error": "Erro interno reportado na mensagem",
"Application startup failed": "Falha de arranque reportada na mensagem",
"sqlalchemy.exc.": "sqlalchemy exc.",
"psycopg.errors.": "psycopg errors.",
"SyntaxError:": "SyntaxError reportado:",
"Exception:": "Exceção reportada:",
}
for old, new in replacements.items():
text = text.replace(old, new)
text = text.replace(old.lower(), new)
text = text.replace(old.upper(), new)
return text
def _tasks_for_filters(status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200):
effective_status = status or "pending"
status_filter = None if effective_status == "all" else effective_status
tasks = list_tasks(status=status_filter, route=route, q=q, limit=limit)
if view == "overdue":
tasks = [task for task in tasks if is_task_overdue(task)]
elif view == "today":
tasks = [task for task in tasks if is_task_today(task)]
return tasks
def render_tasks_list_partial(tasks: list[dict]) -> str:
rows = ""
for task in tasks:
task_id = str(task.get("id") or "")
action_code = str(task.get("action_code") or "")
customer = customer_display(task)
subject = compact_text(task.get("message_subject") or "", 80)
detail = compact_text(task_next_action_text(task), 150)
opp_id = opportunity_id_from_task(task)
source_system = str(task.get("source_system") or "").strip()
conversation_id = str(task.get("conversation_id") or "").strip()
source_line = ""
if source_system or conversation_id:
source_bits = []
if source_system:
source_bits.append(source_system.capitalize())
if conversation_id:
source_bits.append(f"conversa #{conversation_id}")
source_line = '<div class="small text-secondary">Origem: ' + esc(" · ".join(source_bits)) + '</div>'
opp_line = f'<div class="small"><a class="cf-inline-link" href="/opportunities/{esc(opp_id)}">Abrir oportunidade</a></div>' if opp_id else '<div class="small text-secondary">Sem oportunidade associada</div>'
rows += f'''
<tr>
<td>{task_priority_chip(task)}<div class="mt-1"><span class="cf-chip cf-chip-gray">task</span></div></td>
<td>
<a class="cf-row-link" href="/tasks/{esc(task_id)}">{esc(customer)}</a>
<div class="small text-secondary text-break">{esc(subject or '')}</div>
{source_line}
{opp_line}
</td>
<td>{route_badge(task.get('route'))}</td>
<td>{status_badge(task.get('status'))}<div class="mt-1">{sla_badge_html(task)}</div></td>
<td>
<strong>{esc(action_label(action_code))}</strong>
<div class="small text-secondary text-break mt-1">{esc(detail or '')}</div>
</td>
<td class="text-end"><div class="d-flex justify-content-end gap-1 flex-wrap"><a class="btn btn-sm btn-outline-primary" href="/tasks/{esc(task_id)}">Abrir</a>{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''}</div></td>
</tr>
'''
if not rows:
rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem tarefas para estes filtros.</td></tr>'
return f'''
<div id="tasks-list" class="cf-live-panel" aria-live="polite">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<span class="small text-secondary">{len(tasks)} resultado(s)</span>
<span class="small text-secondary htmx-indicator" id="tasks-loading">A atualizar…</span>
</div>
<section class="card cf-card"><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Prioridade</th><th>Cliente / oportunidade</th><th>Fila</th><th>Estado</th><th>Próxima ação</th><th></th></tr></thead><tbody>{rows}</tbody></table></div></section>
</div>
'''
@router.get("/tasks/partials/list", response_class=HTMLResponse)
async def tasks_list_partial(status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200):
tasks = _tasks_for_filters(status=status, route=route, view=view, q=q, limit=limit)
return HTMLResponse(render_tasks_list_partial(tasks))
@router.get("/tasks", response_class=HTMLResponse)
async def tasks_page(
status: Optional[str] = "pending",
route: Optional[str] = None,
view: Optional[str] = None,
q: Optional[str] = None,
limit: int = 200,
):
effective_status = status or "pending"
tasks = _tasks_for_filters(status=status, route=route, view=view, q=q, limit=limit)
metrics = get_admin_dashboard_metrics()
def n(key):
return int(metrics.get(key) or 0)
active_key = view if view else (route if route else effective_status)
tabs = [
("pending", "Pendentes", n("pending_total"), "/tasks?status=pending"),
("overdue", "Atrasadas", n("overdue_total"), "/tasks?status=pending&view=overdue"),
("vendas", "Vendas", n("pending_vendas"), "/tasks?status=pending&route=vendas"),
("financeiro", "Financeiro", n("pending_financeiro"), "/tasks?status=pending&route=financeiro"),
("operacoes", "Operações", n("pending_operacoes"), "/tasks?status=pending&route=operacoes"),
("rever", "Revisão", n("pending_rever"), "/tasks?status=pending&route=rever"),
("all", "Todas", n("pending_total") + n("done_total") + n("skipped_total") + n("failed_total"), "/tasks?status=all"),
]
tab_html = "".join(
f'<a class="cf-task-tab {"active" if key == active_key else ""}" href="{href}" hx-get="/tasks/partials/list{href[href.find("?"):] if "?" in href else ""}" hx-target="#tasks-list" hx-swap="outerHTML" hx-push-url="{href}" hx-indicator="#tasks-loading">{esc(label)} <span>{count}</span></a>'
for key, label, count, href in tabs
)
selected = lambda value, current: "selected" if str(value or "") == str(current or "") else ""
cards = ""
for task in tasks:
task_id = str(task.get("id") or "")
action_code = str(task.get("action_code") or "")
customer = customer_display(task)
subject = compact_text(task.get("message_subject") or "Sem assunto", 80)
next_action = task_next_action_text(task)
message = compact_text(task.get("request_text") or task.get("note") or "", 130)
opp_id = opportunity_id_from_task(task)
opp_html = f'<a class="cf-inline-link" href="/opportunities/{esc(opp_id)}">Oportunidade</a>' if opp_id else '<span class="text-secondary">Sem oportunidade</span>'
cards += f'''
<article class="cf-task-card">
<div class="cf-task-card-head">
<div>
<div class="cf-task-action">{esc(action_label(action_code))}</div>
<h2><a href="/tasks/{esc(task_id)}">{esc(customer)}</a></h2>
</div>
<div class="cf-task-badges">{task_priority_chip(task)}{status_badge(task.get('status'))}</div>
</div>
<div class="cf-task-next">
<span>Próxima ação</span>
<strong>{esc(next_action)}</strong>
</div>
<div class="cf-task-meta-row">
{route_badge(task.get('route'))}
{sla_badge_html(task)}
<span class="cf-chip cf-chip-gray">{esc(fmt_dt(task.get('updated_at') or task.get('created_at')))}</span>
</div>
<p class="cf-task-message">{esc(message or subject or '')}</p>
<div class="cf-task-card-foot">
{opp_html}
<a class="btn btn-sm btn-primary" href="/tasks/{esc(task_id)}">Abrir</a>
{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''}
</div>
</article>
'''
if not cards:
cards = '<section class="cf-empty">Sem tarefas para estes filtros.</section>'
table_rows = ""
for task in tasks:
task_id = str(task.get("id") or "")
action_code = str(task.get("action_code") or "")
customer = customer_display(task)
subject = compact_text(task.get("message_subject") or "", 80)
detail = compact_text(task_next_action_text(task), 150)
opp_id = opportunity_id_from_task(task)
source_system = str(task.get("source_system") or "").strip()
conversation_id = str(task.get("conversation_id") or "").strip()
source_line = ""
if source_system or conversation_id:
source_bits = []
if source_system:
source_bits.append(source_system.capitalize())
if conversation_id:
source_bits.append(f"conversa #{conversation_id}")
source_line = '<div class="small text-secondary">Origem: ' + esc(" · ".join(source_bits)) + '</div>'
opp_line = f'<div class="small"><a class="cf-inline-link" href="/opportunities/{esc(opp_id)}">Abrir oportunidade</a></div>' if opp_id else '<div class="small text-secondary">Sem oportunidade associada</div>'
table_rows += f'''
<tr>
<td>{task_priority_chip(task)}<div class="mt-1"><span class="cf-chip cf-chip-gray">task</span></div></td>
<td>
<a class="cf-row-link" href="/tasks/{esc(task_id)}">{esc(customer)}</a>
<div class="small text-secondary text-break">{esc(subject or '')}</div>
{source_line}
{opp_line}
</td>
<td>{route_badge(task.get('route'))}</td>
<td>{status_badge(task.get('status'))}<div class="mt-1">{sla_badge_html(task)}</div></td>
<td>
<strong>{esc(action_label(action_code))}</strong>
<div class="small text-secondary text-break mt-1">{esc(detail or '')}</div>
</td>
<td class="text-end"><div class="d-flex justify-content-end gap-1 flex-wrap"><a class="btn btn-sm btn-outline-primary" href="/tasks/{esc(task_id)}">Abrir</a>{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''}</div></td>
</tr>
'''
if not table_rows:
table_rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem tarefas para estes filtros.</td></tr>'
body = f'''
<style>
.cf-task-tabs {{ display:flex; flex-wrap:wrap; gap:.55rem; margin-bottom:1rem; }}
.cf-task-tab {{ display:inline-flex; align-items:center; gap:.45rem; padding:.62rem .86rem; border-radius:999px; background:#fff; border:1px solid #e2e8f0; color:#334155; text-decoration:none; font-weight:800; }}
.cf-task-tab span {{ background:#f1f5f9; color:#475569; padding:.08rem .45rem; border-radius:999px; font-size:.75rem; }}
.cf-task-tab.active {{ color:#fff; background:#0d6efd; border-color:#0d6efd; }}
.cf-task-tab.active span {{ background:rgba(255,255,255,.22); color:#fff; }}
.cf-task-hero-grid {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:1rem; margin-bottom:1rem; }}
.cf-task-hero-card {{ background:#fff; border:1px solid #e5e7eb; border-radius:1rem; padding:1rem; box-shadow:var(--cf-shadow); }}
.cf-task-hero-card span {{ color:#64748b; font-size:.78rem; font-weight:800; text-transform:uppercase; }}
.cf-task-hero-card strong {{ display:block; font-size:1.65rem; line-height:1.1; margin-top:.25rem; }}
.cf-task-grid-list {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(310px,1fr)); gap:1rem; }}
.cf-task-card {{ background:#fff; border:1px solid #e5e7eb; border-radius:1.1rem; padding:1rem; box-shadow:var(--cf-shadow); display:grid; gap:.8rem; }}
.cf-task-card-head {{ display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; }}
.cf-task-card h2 {{ font-size:1.02rem; margin:.15rem 0 0; line-height:1.25; }}
.cf-task-card h2 a {{ color:#0f172a; text-decoration:none; }}
.cf-task-action {{ color:#0d6efd; font-weight:900; font-size:.78rem; text-transform:uppercase; letter-spacing:.02em; }}
.cf-task-badges {{ display:flex; flex-direction:column; gap:.35rem; align-items:flex-end; }}
.cf-task-next {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.8rem; }}
.cf-task-next span {{ color:#64748b; font-size:.74rem; font-weight:900; text-transform:uppercase; }}
.cf-task-next strong {{ display:block; margin-top:.15rem; }}
.cf-task-meta-row {{ display:flex; flex-wrap:wrap; gap:.45rem; align-items:center; }}
.cf-task-message {{ color:#475569; margin:0; line-height:1.45; min-height:2.8em; }}
.cf-task-card-foot {{ display:flex; justify-content:space-between; align-items:center; gap:.75rem; border-top:1px solid #eef2f7; padding-top:.8rem; }}
.cf-inline-link {{ color:#0d6efd; font-weight:800; text-decoration:none; }}
@media (max-width: 1000px) {{ .cf-task-hero-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); }} }}
@media (max-width: 640px) {{ .cf-task-hero-grid {{ grid-template-columns:1fr; }} .cf-task-badges {{ align-items:flex-start; }} .cf-task-card-head {{ flex-direction:column; }} }}
</style>
<section class="cf-task-hero-grid">
<a class="cf-task-hero-card text-reset" href="/tasks?status=pending"><span>Pendentes</span><strong>{n('pending_total')}</strong><small>precisam de ação</small></a>
<a class="cf-task-hero-card text-reset" href="/tasks?status=pending&view=overdue"><span>Atrasadas</span><strong>{n('overdue_total')}</strong><small>prioridade máxima</small></a>
<a class="cf-task-hero-card text-reset" href="/tasks?status=pending&route=financeiro"><span>Financeiro</span><strong>{n('pending_financeiro')}</strong><small>pagamentos/faturas</small></a>
<a class="cf-task-hero-card text-reset" href="/tasks?status=pending&route=operacoes"><span>Operações</span><strong>{n('pending_operacoes')}</strong><small>envios/recolhas</small></a>
</section>
<nav class="cf-task-tabs" aria-label="Filtros rápidos">{tab_html}</nav>
<section class="card cf-card cf-filter-card">
<form method="get" action="/tasks" class="row g-3 align-items-end" hx-get="/tasks/partials/list" hx-target="#tasks-list" hx-swap="outerHTML" hx-push-url="true" hx-indicator="#tasks-loading">
<div class="col-lg-5"><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, email, ação, assunto..."></div>
<div class="col-md-2"><label class="form-label small fw-bold text-secondary">Fila</label><select class="form-select" name="route"><option value="" {selected('', route)}>Todas</option><option value="vendas" {selected('vendas', route)}>Vendas</option><option value="financeiro" {selected('financeiro', route)}>Financeiro</option><option value="operacoes" {selected('operacoes', route)}>Operações</option><option value="suporte" {selected('suporte', route)}>Suporte</option><option value="rever" {selected('rever', route)}>Revisão</option></select></div>
<div class="col-md-2"><label class="form-label small fw-bold text-secondary">Estado</label><select class="form-select" name="status"><option value="all" {selected('all', effective_status)}>Todos</option><option value="pending" {selected('pending', effective_status)}>Pendentes</option><option value="done" {selected('done', effective_status)}>Concluídas</option><option value="skipped" {selected('skipped', effective_status)}>Ignoradas</option><option value="failed" {selected('failed', effective_status)}>Falhas</option></select></div>
<div class="col-md-3 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/tasks">Limpar</a></div>
</form>
</section>
<section class="card cf-card mb-3"><div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-2"><div><h2 class="cf-section-title mb-1">Lista de tarefas abertas</h2><div class="small text-secondary">Mesma leitura da Fila operacional: prioridade, cliente/oportunidade, fila, estado e próxima ação. Esta página mostra apenas tasks humanas.</div></div><span class="cf-chip cf-chip-gray">{len(tasks)} resultado(s)</span></div></section>
{render_tasks_list_partial(tasks)}
'''
return layout("Tarefas", "Fila operacional com foco na próxima ação", body, "tasks")
def render_task_detail_partial(task_id: str, notice: str = "") -> str:
task = get_task_detail(task_id)
if not task:
return '<div id="task-detail-panel" class="cf-empty">Tarefa não encontrada.</div>'
action_code = str(task.get("action_code") or "")
status = str(task.get("status") or "")
route_name = str(task.get("route") or "")
contact_id = str(task.get("contact_id") or "")
local_customer_id = str(task.get("linked_customer_id") or "")
task_customer_id = str(task.get("customer_id") or "")
safe_customer_id = local_customer_id or (task_customer_id if is_uuid_text(task_customer_id) else "")
customer = str(task.get("linked_customer_name") or customer_display(task))
subject = str(task.get("message_subject") or "")
opportunity_id = opportunity_id_from_task(task)
next_action = task_next_action_text(task)
request_text = task.get("request_text") or task.get("clean_body") or task.get("raw_body") or task.get("note") or ""
if len(str(request_text)) > 800:
request_text = str(request_text)[:800] + ""
request_text = _safe_task_display_html(str(request_text))
notice_html = f'<div class="alert alert-success border-0">{esc(notice)}</div>' if notice else ""
customer_link = f'<a class="btn btn-sm btn-outline-secondary" href="/customers/{esc(safe_customer_id)}">Ver cliente fiscal</a>' if safe_customer_id else ""
contact_line = f'<span class="small text-secondary">Contacto Chatwoot: {esc(contact_id)}</span>' if contact_id and not safe_customer_id else ""
opportunity_link = f'<a class="btn btn-sm btn-outline-primary" href="/opportunities/{esc(opportunity_id)}">Ver oportunidade</a>' if opportunity_id else '<span class="small text-secondary">Sem oportunidade associada</span>'
chatwoot_html = chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else ''
fiscal_customer = {
"id": safe_customer_id,
"name": task.get("linked_customer_name"),
"email": task.get("linked_customer_email"),
"tax_id": task.get("linked_customer_tax_id"),
"street_name": task.get("linked_customer_street_name"),
"postal_zone": task.get("linked_customer_postal_zone"),
"city_name": task.get("linked_customer_city_name"),
"phone": task.get("linked_customer_phone"),
} if safe_customer_id or task.get("linked_customer_name") else None
fiscal_contact_html = fiscal_contact_panel_html(
fiscal_customer=fiscal_customer,
contact_name=task.get("customer_name") or customer,
contact_email=task.get("customer_email"),
contact_phone=task.get("customer_phone"),
conversation_id=task.get("conversation_id"),
contact_id=task.get("contact_id"),
customer_href=f"/customers/{esc(safe_customer_id)}" if safe_customer_id else "",
)
fiscal_missing_labels = fiscal_customer_missing_fields(fiscal_customer) if action_code in {"SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE"} else []
task_readiness_html = readiness_checklist_html(
title="Prontidão mínima antes de documento/envio",
missing=fiscal_missing_labels,
ok_text="Sem bloqueios fiscais mínimos para esta tarefa.",
blocked_text="Corrigir estes dados antes de emitir documento.",
)
done_controls = ""
if status == "pending":
done_note_options = done_note_options_html_for(action_code) or ""
done_controls = f'''
<form method="post" action="/tasks/{esc(task_id)}/complete-with-note" hx-post="/tasks/{esc(task_id)}/complete-with-note" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Confirmar conclusão desta tarefa?" class="vstack gap-2">
<select class="form-select" name="done_note" aria-label="Resultado">{done_note_options}</select>
<textarea class="form-control" name="done_note_extra" rows="2" placeholder="Nota opcional"></textarea>
<button class="btn btn-success" type="submit">Marcar como feita</button>
</form>
'''
else:
done_controls = f'<div class="alert alert-secondary mb-0">Estado atual: <strong>{esc(status)}</strong>.</div>'
html = f'''
<div id="task-detail-panel" class="cf-live-panel" aria-live="polite">
<div class="d-flex justify-content-end mb-2"><span class="small text-secondary htmx-indicator" id="task-detail-loading">A atualizar…</span></div>
{notice_html}
<section class="card cf-card mb-3">
<div class="card-body p-4 d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="text-primary fw-bold mb-1">Próxima ação</div>
<h2 class="h3 fw-bold mb-2">{esc(action_label(action_code))}</h2>
<div class="d-flex flex-wrap gap-2">{status_badge(status)}{route_badge(route_name)}<code>{esc(action_code or '')}</code></div>
<div class="mt-3 fw-semibold">{esc(customer)}</div>
<div class="small text-secondary text-break">{esc(subject)}</div>
</div>
<div class="d-flex flex-wrap gap-2">{customer_link}{opportunity_link}{chatwoot_html}</div>
</div>
</section>
<div class="row g-3">
<main class="col-12 col-lg-8 d-grid gap-3">
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-2">Próxima ação</h2><strong>{esc(next_action)}</strong><div class="d-flex flex-wrap gap-2 mt-3">{route_badge(route_name)}{status_badge(status)}{task_priority_chip(task)}</div></div></section>
{fiscal_contact_html}
{task_readiness_html}
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-2">Pedido do cliente</h2><div class="cf-copy-box">{esc(str(request_text))}</div></div></section>
</main>
<aside class="col-12 col-lg-4 d-grid gap-3 align-content-start">
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Ligações</h2><div class="d-grid gap-2">{customer_link}{contact_line}{opportunity_link}</div></div></section>
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Concluir</h2>{done_controls}</div></section>
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Ações técnicas</h2><form method="post" action="/tasks/{esc(task_id)}/skip" hx-post="/tasks/{esc(task_id)}/skip" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Ignorar esta tarefa? Esta ação deve ser usada apenas quando não há trabalho operacional a fazer."><button class="btn btn-outline-danger w-100" type="submit">Ignorar tarefa</button></form></div></section>
</aside>
</div>
</div>
'''
return _safe_task_display_html(html)
@router.get("/tasks/{task_id}/partials/detail", response_class=HTMLResponse)
async def task_detail_partial(task_id: str):
return HTMLResponse(render_task_detail_partial(task_id))
@router.get("/tasks/{task_id}", response_class=HTMLResponse)
async def task_detail_bootstrap_page(task_id: str):
task = get_task_detail(task_id)
if not task:
return HTMLResponse("<h1>Tarefa não encontrada</h1>", status_code=404)
action_code = str(task.get("action_code") or "")
route_name = str(task.get("route") or "")
status = str(task.get("status") or "")
conversation_id = str(task.get("conversation_id") or "")
contact_id = str(task.get("contact_id") or "")
local_customer_id = str(task.get("linked_customer_id") or "")
task_customer_id = str(task.get("customer_id") or "")
safe_customer_id = local_customer_id or (task_customer_id if is_uuid_text(task_customer_id) else "")
customer = str(task.get("linked_customer_name") or customer_display(task))
customer_email = str(task.get("customer_email") or "")
customer_phone = str(task.get("customer_phone") or "")
subject = str(task.get("message_subject") or "")
opportunity_id = opportunity_id_from_task(task)
request_text = (
task.get("request_text")
or task.get("clean_body")
or task.get("raw_body")
or task.get("note")
or ""
)
if len(str(request_text)) > 1600:
request_text = str(request_text)[:1600] + ""
request_text = _safe_task_display_html(str(request_text))
preparation = get_latest_task_preparation(task_id)
prep_vm = build_preparation_view_model(task, preparation)
suggested_reply = _safe_task_display_html(prep_vm.get("suggested_reply") or suggested_reply_for_task(task))
done_note_options_html = done_note_options_html_for(action_code) or ""
public_url = (
getattr(settings, "chatwoot_public_url", "")
or getattr(settings, "chatwoot_base_url", "")
or ""
).rstrip("/")
account_id = getattr(settings, "chatwoot_account_id", "")
chatwoot_link = ""
if public_url and account_id and conversation_id:
href = f"{public_url}/app/accounts/{account_id}/conversations/{conversation_id}"
chatwoot_link = f'<a class="btn btn-outline-primary" href="{esc(href)}" target="_blank" rel="noopener">Abrir Chatwoot ↗</a>'
fiscal_customer = {
"id": safe_customer_id,
"name": task.get("linked_customer_name"),
"email": task.get("linked_customer_email"),
"tax_id": task.get("linked_customer_tax_id"),
"street_name": task.get("linked_customer_street_name"),
"postal_zone": task.get("linked_customer_postal_zone"),
"city_name": task.get("linked_customer_city_name"),
"phone": task.get("linked_customer_phone"),
} if safe_customer_id or task.get("linked_customer_name") else None
fiscal_contact_html = fiscal_contact_panel_html(
fiscal_customer=fiscal_customer,
contact_name=task.get("customer_name") or customer,
contact_email=customer_email,
contact_phone=customer_phone,
conversation_id=conversation_id,
contact_id=contact_id,
customer_href=f"/customers/{esc(safe_customer_id)}" if safe_customer_id else "",
)
fiscal_missing_labels = fiscal_customer_missing_fields(fiscal_customer) if action_code in {"SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE"} else []
fiscal_readiness_html = readiness_checklist_html(
title="Prontidão fiscal da tarefa",
missing=fiscal_missing_labels,
ok_text="Sem bloqueios fiscais mínimos para esta tarefa.",
blocked_text="Corrigir estes dados antes de emitir documento.",
)
missing_items = list(prep_vm.get("missing_fields") or [])
existing_missing_labels = {str(item.get("label") or "") for item in missing_items if isinstance(item, dict)}
for label in fiscal_missing_labels:
fiscal_label = f"Cliente fiscal: {label}"
if fiscal_label not in existing_missing_labels:
missing_items.append({"label": fiscal_label})
if missing_items:
missing_html = "".join(
f'<span class="cf-missing-pill">⚠ {esc(item.get("label"))}</span>'
for item in missing_items
)
else:
missing_html = '<span class="badge text-bg-success-subtle text-success border border-success-subtle">Sem dados críticos em falta</span>'
confirmed = prep_vm.get("confirmed_fields") or []
confirmed_html = "".join(
f"<div class=\"cf-confirmed-row\"><span>{esc(item.get('label'))}</span><strong>{esc(item.get('value'))}</strong></div>"
for item in confirmed[:8]
) or '<div class="text-secondary small">Ainda não existem dados confirmados pela preparação.</div>'
prep_type = str(prep_vm.get("prep_type") or "generic")
assistant_buttons = ""
if action_code == "SEND_PROFORMA":
assistant_buttons += f'<form method="post" action="/tasks/{esc(task_id)}/prepare-proforma" hx-post="/tasks/{esc(task_id)}/prepare-proforma" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading"><button class="btn btn-outline-primary w-100" type="submit">Preparar pró-forma</button></form>'
if action_code in {"CONFIRM_PAYMENT", "SUPPORT"}:
assistant_buttons += f'<form method="post" action="/tasks/{esc(task_id)}/prepare-shipment" hx-post="/tasks/{esc(task_id)}/prepare-shipment" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading"><button class="btn btn-outline-primary w-100" type="submit">Preparar envio</button></form>'
assistant_buttons += f'<form method="post" action="/tasks/{esc(task_id)}/prepare-pickup" hx-post="/tasks/{esc(task_id)}/prepare-pickup" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading"><button class="btn btn-outline-primary w-100" type="submit">Preparar recolha</button></form>'
if not assistant_buttons:
assistant_buttons = '<div class="text-secondary small">Sem assistente específico para esta ação.</div>'
completion_html = ""
if status == "pending":
completion_html = f"""
<form method="post" action="/tasks/{esc(task_id)}/complete-with-note" hx-post="/tasks/{esc(task_id)}/complete-with-note" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Confirmar conclusão desta tarefa?" class="vstack gap-2">
<select class="form-select" name="done_note" aria-label="Resultado">{done_note_options_html}</select>
<textarea class="form-control" name="done_note_extra" rows="2" placeholder="Nota opcional"></textarea>
<button class="btn btn-success" type="submit">Marcar como feita</button>
</form>
"""
else:
completion_html = f'<div class="alert alert-secondary mb-0">Estado atual: <strong>{esc(status)}</strong>.</div>'
reclassify_options = [
"SEND_INFO", "SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT",
"SUPPORT", "REMOVE_FROM_LIST", "MARK_NO_INTEREST", "IGNORE_SPAM", "REVIEW_MANUALLY", "NO_ACTION",
]
reclassify_options_html = "".join(
f'<option value="{esc(code)}" {"selected" if code == action_code else ""}>{esc(code)}</option>'
for code in reclassify_options
)
technical = prep_vm.get("technical") or {}
technical_blocks = "".join(
f"<div class=\"col-12 col-lg-6 cf-tech-block\"><div class=\"small text-secondary fw-bold\">{esc(label)}</div><pre>{esc(json.dumps(data or {}, ensure_ascii=False, indent=2, default=str))}</pre></div>"
for label, data in [
("Cliente", technical.get("customer")),
("Faturação", technical.get("billing")),
("Venda", technical.get("sale")),
("Logística", technical.get("shipment")),
]
)
confidence_text = ""
action_decision = task.get("action_decision")
if isinstance(action_decision, dict) and action_decision.get("confidence") is not None:
confidence_text = f"{float(action_decision.get('confidence')):.0%} confiança"
body = f"""
<style>
.cf-task-hero {{ border:1px solid #bfdbfe; background:linear-gradient(135deg,#eff6ff,#fff); border-radius:1.25rem; box-shadow:var(--cf-shadow); }}
.cf-task-grid {{ display:grid; grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr); gap:1rem; }}
.cf-action-icon {{ width:3rem;height:3rem;display:grid;place-items:center;border-radius:1rem;background:#0d6efd;color:white;font-size:1.4rem; }}
.cf-missing-pill {{ display:inline-flex;align-items:center;gap:.35rem;border:1px solid #fecaca;background:#fef2f2;color:#991b1b;border-radius:999px;padding:.48rem .7rem;font-weight:700;font-size:.85rem; }}
.cf-confirmed-row {{ display:grid;gap:.15rem;padding:.6rem 0;border-bottom:1px solid #eef2f7; }}
.cf-confirmed-row:last-child {{ border-bottom:0; }}
.cf-confirmed-row span {{ color:#64748b;font-size:.78rem;font-weight:800;text-transform:uppercase;letter-spacing:.02em; }}
.cf-confirmed-row strong {{ color:#0f172a;font-size:.94rem; }}
.cf-message-box {{ white-space:pre-wrap;background:#f8fafc;border:1px solid #dbeafe;border-radius:1rem;padding:1rem;min-height:170px; }}
.cf-tech-block pre {{ max-height:220px;background:#0f172a;color:#e2e8f0;border-radius:.85rem;font-size:.72rem;margin-top:.4rem; }}
@media (max-width: 1000px) {{ .cf-task-grid {{ grid-template-columns:1fr; }} }}
</style>
<a class="cf-row-link d-inline-flex mb-3" href="/tasks">← Voltar a tarefas</a>
<section class="cf-task-hero p-4 mb-3">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div class="d-flex gap-3 align-items-start">
<div class="cf-action-icon">➤</div>
<div>
<div class="text-primary fw-bold mb-1">Próxima ação</div>
<h2 class="h3 fw-bold mb-2">{esc(prep_vm.get('primary_action') or action_label(action_code))}</h2>
<div class="d-flex flex-wrap gap-2">
{status_badge(status)}
{route_badge(route_name)}
{f'<span class="badge text-bg-success-subtle text-success border border-success-subtle">{esc(confidence_text)}</span>' if confidence_text else ''}
<code>{esc(action_code or '')}</code>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-outline-primary" type="button" onclick="copySuggestedReply()">Copiar mensagem</button>
{chatwoot_link or ''}
<form method="post" action="/tasks/{esc(task_id)}/complete-with-note" hx-post="/tasks/{esc(task_id)}/complete-with-note" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Confirmar que o contacto foi tratado?">
<input type="hidden" name="done_note" value="Pedido/contacto tratado pelo operador.">
<button class="btn btn-primary" type="submit">Marcar contacto feito</button>
</form>
</div>
</div>
</section>
<div class="cf-task-grid">
<main class="d-grid gap-3">
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Dados em falta</h2>
<div class="d-flex flex-wrap gap-2">{missing_html}</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
<h2 class="cf-section-title mb-0">Mensagem sugerida</h2>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="copySuggestedReply()">Copiar</button>
</div>
<div id="suggested-reply-text" class="cf-message-box">{esc(suggested_reply)}</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Pedido do cliente</h2>
<div class="small text-secondary fw-bold mb-1">Assunto</div>
<div class="fw-semibold mb-3">{esc(subject)}</div>
<div class="small text-secondary fw-bold mb-1">Mensagem</div>
<div class="cf-copy-box">{esc(str(request_text))}</div>
</div></section>
</main>
<aside class="d-grid gap-3 align-content-start">
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Dados confirmados</h2>
{confirmed_html}
</div></section>
{fiscal_contact_html}
{fiscal_readiness_html}
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Ligações</h2>
<div class="d-grid gap-2">
{f'<a class="btn btn-outline-primary" href="/opportunities/{esc(opportunity_id)}">Ver oportunidade</a>' if opportunity_id else ''}
{chatwoot_link or '<span class="text-secondary small">Chatwoot não configurado.</span>'}
</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Assistentes operacionais</h2>
<div class="small text-secondary mb-2">Última preparação: {esc(prep_type)}</div>
<div class="vstack gap-2">{assistant_buttons}</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Concluir</h2>
{completion_html}
</div></section>
</aside>
</div>
<details class="card cf-card mt-3">
<summary class="card-body p-4 fw-bold text-primary" style="cursor:pointer">Ver detalhes técnicos</summary>
<div class="card-body border-top p-4">
<div class="row g-3">
<div class="col-12 col-lg-6">
<h3 class="h6 fw-bold">Reclassificar</h3>
<form method="post" action="/tasks/{esc(task_id)}/reclassify" hx-post="/tasks/{esc(task_id)}/reclassify" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Guardar reclassificação manual desta tarefa?" class="vstack gap-2">
<select class="form-select" name="action_code">{reclassify_options_html}</select>
<textarea class="form-control" name="reason" rows="3" placeholder="Motivo da correção">{esc(task.get('note') or '')}</textarea>
<button class="btn btn-outline-primary" type="submit">Guardar reclassificação</button>
</form>
</div>
<div class="col-12 col-lg-6">
<h3 class="h6 fw-bold">Ignorar</h3>
<form method="post" action="/tasks/{esc(task_id)}/skip" hx-post="/tasks/{esc(task_id)}/skip" hx-target="#task-detail-panel" hx-swap="outerHTML" hx-indicator="#task-detail-loading" hx-confirm="Ignorar esta tarefa?">
<button class="btn btn-outline-danger" type="submit">Ignorar tarefa</button>
</form>
</div>
</div>
<hr>
<div class="row g-3">{technical_blocks}</div>
</div>
</details>
<script>
function copySuggestedReply() {{
const el = document.getElementById("suggested-reply-text");
if (!el) return;
navigator.clipboard.writeText(el.innerText || el.textContent || "");
}}
</script>
"""
body = _safe_task_display_html(body)
body = f'<div id="task-detail-panel" class="cf-live-panel" aria-live="polite">{body}</div>'
return layout(
f"{action_label(action_code)}{customer}",
"Executar a próxima ação sem informação repetida.",
body,
"tasks",
)
@router.post("/tasks/{task_id}/prepare-pickup")
async def prepare_pickup_endpoint(task_id: str, request: Request):
await run_in_threadpool(run_task_preparation, task_id=task_id, prep_type="pickup")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Preparação de recolha atualizada."))
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
@router.post("/tasks/{task_id}/prepare-shipment")
async def prepare_shipment_endpoint(task_id: str, request: Request):
await run_in_threadpool(run_task_preparation, task_id=task_id, prep_type="shipment")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Preparação de envio atualizada."))
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
@router.post("/tasks/{task_id}/prepare-proforma")
async def prepare_proforma_endpoint(task_id: str, request: Request):
await run_in_threadpool(run_task_preparation, task_id=task_id, prep_type="proforma")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Preparação de pró-forma atualizada."))
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
@router.post("/tasks/{task_id}/reclassify")
async def reclassify_task_endpoint(task_id: str, request: Request):
from urllib.parse import parse_qs
raw_body = (await request.body()).decode("utf-8", errors="replace")
form = parse_qs(raw_body)
action_code = (form.get("action_code") or [""])[0].strip()
reason = (form.get("reason") or [""])[0].strip()
if not action_code:
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
try:
reclassify_task(
task_id=task_id,
new_action_code=action_code,
reason=reason,
reclassified_by="operator",
reopen=True,
)
except Exception as exc:
print(
f"ClientFlow reclassify failed "
f"task_id={task_id} action_code={action_code}: {exc!r}",
flush=True,
)
raise
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa reclassificada."))
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
@router.post("/tasks/{task_id}/complete")
async def complete_task_endpoint(task_id: str, request: Request):
complete_task(task_id=task_id, done_by="operator")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa concluída."))
return RedirectResponse("/tasks?status=pending", status_code=303)
@router.post("/tasks/{task_id}/complete-with-note")
async def complete_task_with_note_action(
task_id: str,
request: Request,
):
form = await request.form()
done_note = str(form.get("done_note") or "").strip()
done_note_extra = str(form.get("done_note_extra") or "").strip()
if done_note_extra:
if done_note:
done_note = f"{done_note}{done_note_extra}"
else:
done_note = done_note_extra
complete_task_with_note(
task_id,
done_by="admin",
done_note=done_note,
)
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa concluída."))
return RedirectResponse(
url=f"/tasks/{task_id}",
status_code=303,
)
@router.post("/tasks/{task_id}/skip")
async def skip_task_endpoint(task_id: str, request: Request):
skip_task(task_id=task_id, skipped_by="operator", reason="Skipped from dashboard")
if is_htmx(request):
return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa ignorada."))
return RedirectResponse("/tasks", status_code=303)

52
app/admin_ui/router.py Normal file
View File

@@ -0,0 +1,52 @@
"""Aggregate router for the modular ClientFlow admin UI.
As of v4.7.2, route registration is split by domain under
``app.admin_ui.pages``. Handler internals are intentionally reused from the
legacy module where needed so URLs and behavior remain stable.
"""
from fastapi import APIRouter, Depends
from app.admin_dashboard import require_admin_access, router as legacy_router
from app.admin_ui.pages import dashboard
from app.admin_ui.pages import operations
from app.admin_ui.pages import opportunities
from app.admin_ui.pages import tasks
from app.admin_ui.pages import customers
from app.admin_ui.pages import products
from app.admin_ui.pages import orders
from app.admin_ui.pages import finance
from app.admin_ui.pages import integrations
from app.admin_ui.pages import communications
from app.admin_ui.pages import outbox
from app.admin_ui.pages import events
from app.admin_ui.pages import runs
from app.admin_ui.pages import queues
from app.admin_ui.pages import conversations
from app.admin_ui.pages import system
from app.admin_ui.pages import reconciliation
router = APIRouter(prefix="", tags=["admin"], dependencies=[Depends(require_admin_access)])
# Keep the legacy CSS endpoint available while page routes live in domain modules.
router.include_router(legacy_router)
router.include_router(dashboard.router)
router.include_router(operations.router)
router.include_router(reconciliation.router)
router.include_router(opportunities.router)
router.include_router(tasks.router)
router.include_router(customers.router)
router.include_router(products.router)
router.include_router(orders.router)
router.include_router(finance.router)
router.include_router(integrations.router)
router.include_router(communications.router)
router.include_router(outbox.router)
router.include_router(events.router)
router.include_router(runs.router)
router.include_router(queues.router)
router.include_router(conversations.router)
router.include_router(system.router)
__all__ = ["router"]

2439
app/admin_ui/styles.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
"""View-model builders used by the modular admin UI."""

View File

@@ -0,0 +1,235 @@
"""View model for the operator workbench.
This module is intentionally UI-focused: it does not change workflow rules, it
only prepares the data returned by operations_service for full-page and HTMX
partial rendering.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from app.operations_service import get_operations_summary
from app.admin_ui.labels import primary_action_label
from app.admin_ui.guidance import item_requires_fiscal_customer, work_item_blockers
# Legacy label kept for regression context: Ambíguas.
OPERATION_FILTERS = [
("all", "A fazer", "/operations?scope=all", "/operations/partials/work-items?scope=all"),
("bloqueadas", "Bloqueadas", "/operations?scope=bloqueadas", "/operations/partials/work-items?scope=bloqueadas"),
("ambiguas", "Associações por confirmar", "/operations?scope=ambiguas", "/operations/partials/work-items?scope=ambiguas"),
("atrasadas", "Atrasadas", "/operations?scope=atrasadas", "/operations/partials/work-items?scope=atrasadas"),
("vendas", "Vendas", "/operations?scope=vendas", "/operations/partials/work-items?scope=vendas"),
("financeiro", "Financeiro", "/operations?scope=financeiro", "/operations/partials/work-items?scope=financeiro"),
("logistica", "Logística", "/operations?scope=logistica", "/operations/partials/work-items?scope=logistica"),
("revisao", "Revisão", "/operations?scope=revisao", "/operations/partials/work-items?scope=revisao"),
("done", "Concluídas hoje", "/tasks?status=done", "/tasks/partials/list?status=done"),
]
def _is_numeric_reference(value: Any) -> bool:
value = str(value or "").strip()
return bool(value) and value.isdigit()
def is_classification_failed(item: dict[str, Any]) -> bool:
detail = str(item.get("detail") or "").casefold()
return "classificação da mensagem falhou" in detail or "classificacao da mensagem falhou" in detail
def has_no_commercial_opportunity(item: dict[str, Any]) -> bool:
reason = str(item.get("no_opportunity_reason") or "").strip()
action_code = str(item.get("action_code") or "").upper()
return bool(reason) or action_code in {"REVIEW_MANUALLY", "REMOVE_FROM_LIST", "IGNORE_SPAM", "NO_ACTION"}
def _looks_like_generic_label(value: str) -> bool:
value = " ".join(str(value or "").strip().casefold().split())
return value in {"", "geral", "cliente", "contacto", "sem nome"}
# Legacy label intentionally no longer used as primary title: "Contacto Chatwoot #".
# It is only suitable as technical metadata/fallback, not as operator identity.
def operation_card_title(item: dict[str, Any]) -> str:
if str(item.get("source") or "") == "outbox":
return str(item.get("customer_name") or item.get("title") or "Integração")
fiscal = str(item.get("fiscal_customer_name") or "").strip()
contact = str(item.get("contact_display_name") or item.get("customer_name") or "").strip()
if fiscal and not _is_numeric_reference(fiscal) and not _looks_like_generic_label(fiscal):
return fiscal
if contact and not _is_numeric_reference(contact) and not _looks_like_generic_label(contact):
return contact
return "Contacto sem identificação"
def operation_card_subtitle(item: dict[str, Any]) -> str:
if is_classification_failed(item):
return "Classificação por rever"
reason = str(item.get("no_opportunity_reason") or "").strip()
if reason in {"system_or_bounce_message", "bounce_ignored"}:
return "Mensagem automática / bounce"
if reason:
return "Sem oportunidade comercial"
opportunity = str(item.get("opportunity_title") or "").strip()
if opportunity:
return opportunity
if str(item.get("source") or "") == "outbox":
return str(item.get("title") or "Integração")
if str(item.get("action_code") or "").upper() in {"REMOVE_FROM_LIST", "IGNORE_SPAM", "NO_ACTION"}:
return "Sem oportunidade comercial"
conv = str(item.get("conversation_id") or "").strip()
if operation_card_title(item) == "Contacto sem identificação" and conv:
return f"Conversa Chatwoot #{conv}"
return "Sem oportunidade associada"
def operation_has_useful_details(item: dict[str, Any]) -> bool:
if str(item.get("source") or "") == "outbox":
return True
if str(item.get("opportunity_id") or "").strip():
return True
if str(item.get("fiscal_customer_name") or "").strip():
return True
if item_requires_fiscal_customer(item):
return True
if work_item_blockers(item):
return True
return False
def operation_card_detail(item: dict[str, Any]) -> str:
if is_classification_failed(item):
return "O sistema não conseguiu classificar com segurança. Abrir no Chatwoot e escolher a ação correta."
reason = str(item.get("no_opportunity_reason") or "").strip()
if reason in {"system_or_bounce_message", "bounce_ignored"}:
return "Mensagem automática detetada. Não entra no fluxo operacional."
if reason == "send_info_without_clear_commercial_intent":
return "Responder sem criar oportunidade até existir intenção comercial clara."
return str(item.get("detail") or item.get("request_text") or "").strip()
def operation_status_chip(item: dict[str, Any]) -> str:
blockers = work_item_blockers(item)
if blockers:
return "bloqueada"
if is_high_priority(item):
return "prioridade alta"
if has_no_commercial_opportunity(item):
return "sem oportunidade comercial"
return "normal"
def is_ambiguous(item: dict[str, Any]) -> bool:
return str(item.get("opportunity_linking_status") or "").lower() == "ambiguous"
def is_overdue(item: dict[str, Any]) -> bool:
created = item.get("created_at")
if not created:
return False
if isinstance(created, str):
try:
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
except ValueError:
return False
else:
created_dt = created
if created_dt.tzinfo is None:
created_dt = created_dt.replace(tzinfo=timezone.utc)
queue = str(item.get("queue") or "").lower()
hours = {"suporte": 2, "vendas": 4, "financeiro": 8, "operacoes": 24, "logistica": 24, "rever": 24}.get(queue, 24)
return (datetime.now(timezone.utc) - created_dt).total_seconds() > hours * 3600
def is_high_priority(item: dict[str, Any]) -> bool:
status = str(item.get("status") or "").lower()
source = str(item.get("source") or "").lower()
queue = str(item.get("queue") or "").lower()
action_code = str(item.get("action_code") or "").upper()
priority = str(item.get("priority") or "").lower()
if is_ambiguous(item):
return True
if source == "outbox" and status in {"failed", "blocked", "stale"}:
return True
if is_overdue(item):
return True
if is_classification_failed(item):
return False
if queue == "marketing" or action_code in {"REMOVE_FROM_LIST", "IGNORE_SPAM", "NO_ACTION", "REVIEW_MANUALLY"}:
return False
if action_code in {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "CREATE_SHIPMENT"}:
return priority in {"alta", "high", "urgente", "critical"} or status in {"failed", "blocked"}
return priority in {"alta", "high", "urgente"} or status in {"failed", "blocked"}
def is_blocked(item: dict[str, Any]) -> bool:
status = str(item.get("status") or "").lower()
source = str(item.get("source") or "").lower()
return status in {"failed", "blocked", "stale"} or source == "outbox" or bool(work_item_blockers(item))
def operation_item_matches_scope(item: dict[str, Any], scope: str) -> bool:
queue = str(item.get("queue") or "").lower()
action_code = str(item.get("action_code") or "").upper()
scope = str(scope or "all").strip().lower()
if scope in {"", "all", "todas"}:
return True
if scope == "bloqueadas":
return is_blocked(item)
if scope in {"ambiguas", "ambíguas"}:
return is_ambiguous(item)
if scope == "atrasadas":
return is_overdue(item)
if scope == "logistica":
return queue in {"operacoes", "logistica", "logística"}
if scope == "revisao":
return queue in {"rever", "revisao", "revisão"} or action_code == "REVIEW_MANUALLY"
return queue == scope
def operation_primary_label(item: dict[str, Any]) -> str:
if is_classification_failed(item):
return "Rever mensagem"
if str(item.get("source") or "") == "outbox":
status = str(item.get("status") or "").lower()
return "Reprocessar" if status == "failed" else "Ver bloqueio"
return primary_action_label(item.get("action_code") or item.get("action_label"), fallback="Abrir")
def operation_origin_label(item: dict[str, Any]) -> str:
source = str(item.get("source_system") or item.get("source") or "").strip()
conv = str(item.get("conversation_id") or "").strip()
if conv:
return f"Chatwoot #{conv}"
if source:
return source
return "ClientFlow"
def build_operations_view_model(scope: str = "all", *, limit: int = 30) -> dict[str, Any]:
data = get_operations_summary(limit=limit)
counts = data.get("counts") or {}
scope = str(scope or "all").strip().lower()
work_items = list(data.get("work_items") or [])
visible_items = [item for item in work_items if operation_item_matches_scope(item, scope)]
high_items = [item for item in visible_items if is_high_priority(item)]
normal_items = [item for item in visible_items if not is_high_priority(item)]
review_total = int(counts.get("review_tasks", 0) or 0) + int(counts.get("communications_needs_review", 0) or 0)
blocked_total = int(counts.get("blocked_outbox", 0) or 0) + sum(1 for item in work_items if is_blocked(item))
ambiguous_total = sum(1 for item in work_items if is_ambiguous(item))
overdue_total = int(counts.get("overdue_tasks", 0) or 0) + sum(1 for item in work_items if item.get("source") == "outbox" and str(item.get("status") or "").lower() in {"failed", "stale"})
return {
"scope": scope,
"counts": counts,
"review_total": review_total,
"blocked_total": blocked_total,
"ambiguous_total": ambiguous_total,
"overdue_total": overdue_total,
"today_label": datetime.now(timezone.utc).strftime("%d/%m/%Y"),
"filters": OPERATION_FILTERS,
"visible_items": visible_items,
"high_items": high_items,
"normal_items": normal_items,
}